Skip to content

release: bring audit stack (19 PRs) into develop#285

Merged
github-actions[bot] merged 22 commits into
developfrom
chore/set-subscription-public-key
Jun 9, 2026
Merged

release: bring audit stack (19 PRs) into develop#285
github-actions[bot] merged 22 commits into
developfrom
chore/set-subscription-public-key

Conversation

@tomymaritano

@tomymaritano tomymaritano commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Why this PR

The audit stack (#266 through #284) was structured as 19 stacked PRs, each one's base pointing at its predecessor in the chain. Each merged into its own parent branch, not into `develop`. Net result: `develop` only contains #265, and the other 19 PRs are stranded on their branch tips.

This PR ships the head of the stack (`chore/set-subscription-public-key`) into `develop` so the work actually lands. It's a single PR with 21 commits — the linear chain of the stack — preserving each individual PR's conventional commit message so semantic-release can categorise them for the next release.

What's in this PR (in merge order)

# Commit Title
1 `3412e4e` fix(typecheck): unblock pnpm -r typecheck after TS 6.x bump (already merged as squash in develop — no-op overlap)
2 `03da9cb` fix(editor): stop runtime crashes in MarkdownEditor
3 `07a76d6` chore(tooling): replace husky with lefthook, add knip, tighten lint-staged
4 `e27f1ba` refactor(stores): use named selectors instead of destructuring full state
5 `c519fc3` chore(test): add coverage baseline + smoke tests for @readied/commands
6 `5b1cc55` chore(mcp-server): migrate to registerTool API + FTS5 for read_note
7 `0eb49a5` fix(backup): integrity-check restored db and roll back on failure
8 `402e280` refactor(ipc): add typed IPC registry, migrate aiKeyHandlers as proof
9 `dd4823d` refactor(ipc): migrate light handlers to defineIpcHandler
10 `5607100` refactor(ipc): migrate heavy data handlers to defineIpcHandler
11 `c51c4d3` fix(aiKeyStorage): stop deleting encrypted keys on transient decrypt errors
12 `d5f33ef` feat(licensing): add Ed25519 subscription envelope sign + verify
13 `5ba96d4` feat(e2e): scaffold Playwright Electron suite + CI job
14 `2bd1396` refactor(main): extract FileLicenseStorage and window state to services
15 `fd9b809` chore(knip): delete verified-unused files (phase 1)
16 `a3d7c1b` chore(knip): remove unused dependencies (phase 2)
17 `0ec48b3` feat(license): wire Ed25519 signed-envelope verification at the storage layer
18 `cca7e04` refactor(storage-sqlite): extract noteMapping helpers from SQLiteNoteRepository
19 `1a0df95` refactor(editor): extract theme + highlight from MarkdownEditor
20 `9034c71` chore(license): set real SUBSCRIPTION_PUBLIC_KEY
21 `390503c` docs(api): document LICENSE_SIGNING_PRIVATE_KEY secret requirement

Each is already individually reviewed and merged on GitHub (#266#284). They appear here as their original commits because the stack used rebase-based stacking, not merge commits.

Pre-merge verification (local, this branch)

  • ✅ `pnpm -r typecheck` — green across 18 workspace projects
  • ✅ `pnpm test` — 17/17 packages
  • ✅ `pnpm build` — 6/6 packages

After this PR merges

Per the release flow in `CLAUDE.md`:

  1. Open `develop → main` PR
  2. Click "Run workflow" on Release action — `semantic-release` analyses these conventional commits and bumps the version
  3. Tag push triggers Build workflow — mac/win/linux in parallel
  4. All builds green → release un-drafts → electron-updater serves the update

Notable behaviour changes for users

Notable for reviewers

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added end-to-end testing for the desktop application with comprehensive smoke and notes testing.
    • Implemented IPC handler validation using Zod schemas for improved type safety.
    • Added subscription envelope signing and verification using Ed25519 cryptography.
  • Bug Fixes

    • Improved encryption error handling and recovery logic.
  • Tests

    • Established centralized test coverage configuration across all packages.
    • Expanded test suites for markdown commands and licensing functionality.
  • Chores

    • Transitioned from Husky to Lefthook for Git hooks management.
    • Refactored internal IPC architecture and service modules.
    • Updated build and deployment configurations.

tomymaritano and others added 21 commits June 8, 2026 03:28
Recent TypeScript 6.x dependency bump introduced three typecheck failures
on develop, which made the pre-push hook block any push from the team.
Fixes are surgical, no code changes:

- tsconfig.base.json: ignoreDeprecations "6.0" silences TS5101 for
  baseUrl in packages/{core,plugin-api,sync-core,wikilinks}. We keep
  baseUrl for now; full removal is a separate migration.
- packages/licensing/tsconfig.json: add types: ["node"] so Buffer
  resolves in validator.ts (the package depends on @types/node already).
- apps/desktop/src/renderer/tsconfig.json: explicit rootDir ".." and
  include preload/api so renderer can import from "../preload/index.ts"
  without TS6059 complaining the imported file is outside rootDir.

Also adds `pnpm -r typecheck` step to the CI typecheck job so monorepo
typecheck never regresses silently again (previously only apps/desktop
was checked in CI; full monorepo was only checked at pre-push).

Verified: pnpm -r typecheck exits 0 on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two production crashes were silently breaking the editor:

1. RangeError "Decorations that replace line breaks may not be specified
   via plugins" — tables.tsx registered Decoration.replace() widgets that
   span multiple lines via ViewPlugin. CodeMirror 6 forbids this; only
   StateField-provided decorations may replace line breaks. Migrated the
   tables WYSIWYG decoration from ViewPlugin.fromClass → StateField:
   - buildTableDecorations now takes EditorState (not EditorView).
   - StateField rebuilds on doc/selection changes, otherwise maps
     decorations through the transaction.
   - Dropped the view.visibleRanges optimization (StateField has no
     viewport). Tables are uncommon enough that processing all of them
     is fine; can be optimized later via StateEffects if needed.

2. TypeError "tags is not iterable" — thrown deep inside
   HighlightStyle.style during syntax tree highlight (not at
   define-time). Suspected cause: a parser extension emits malformed
   tag metadata for a node. We add EditorView.exceptionSink at the head
   of the extension list so plugin crashes are logged (console + Sentry
   if wired) but no longer tear down the editor. Root-cause fix for the
   offending parser is tracked separately — needs runtime repro.

Both errors blocked editor mount in production, leaving users with an
empty note view and "Table WYSIWYG disabled" in the console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…taged

DX upgrade for the monorepo's git hooks and dead-code detection.

Lefthook over husky:
- Single declarative lefthook.yml at repo root (pre-commit, pre-push,
  commit-msg). No more .husky/_ shadow hooks dir.
- Native binary (per-platform via npm), faster than husky's shell wrappers.
- postinstall: lefthook install (replaces husky's prepare script).
- After pulling this PR, contributors who had husky installed may need to
  unset stale core.hooksPath: `git config --unset core.hooksPath`. The
  README will be updated separately; for now the PR description covers it.

Lint-staged tightened:
- Now runs `eslint --cache --fix --max-warnings 0` on *.{ts,tsx,js}
  staged files in addition to prettier. Previously only prettier ran,
  meaning lint errors only surfaced in CI.
- *.{json,md} continue with prettier-only.

Knip added (warn-only baseline):
- knip.json scopes entry points per workspace.
- `pnpm knip` available locally. NOT wired into CI — first pass on the
  full monorepo surfaces ~28 unused files, ~100 unused exports, and
  ~11 unused deps. Cleanup is its own follow-up effort; this PR only
  makes the tool available and the report reproducible.

Rationale: the audit plan called this out as quick DX wins
(.claude/plans/ultracode-quiero-correr-un-enchanted-platypus.md PR-C).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tate

Three components destructured the full auth/sync store on mount, causing
the component to re-render on ANY state change in those stores — even
fields the component never read. Migrated each to per-field selectors so
React + Zustand only re-render when the specific subscribed slice changes.

Files:
- components/auth/MagicLinkFlow.tsx — was reading requestMagicLink,
  isAuthenticated, error.
- components/sync/EnableSyncModal.tsx — same trio.
- pages/settings/sections/AccountSection.tsx — was destructuring
  user/isAuthenticated/isLoading/logout/loadSession from auth and
  syncNow/status/lastSyncAt/conflicts from sync. Now uses individual
  selector calls (selectUser, selectStatus, etc.).

All three stores (auth, sync, settings) already exported named selectors
at the bottom of their files — these components just weren't using them.
The audit flagged these stores as missing selectors entirely; the actual
issue was just three consumer files.

No behavior change. typecheck + tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related changes to give the monorepo a test quality baseline:

1. Smoke tests for @readied/commands (was 0 tests, now 13).
   The package exposes markdown editing commands as pure functions taking
   an EditorView. We can't run a real browser-backed view in vitest, but
   we don't need to — the commands only touch view.state and view.dispatch.
   commands.test.ts fakes a view backed by EditorState.update() so we can
   assert on doc text after each command. Covers: bold/italic/strike/code
   wrap+unwrap, headings, lists, checkbox, quote, code block,
   horizontal rule. Coverage: 78% lines (commands.ts).

2. v8 coverage wired into all 12 vitest configs.
   - New vitest.shared.ts exports a `sharedCoverage` block (provider: v8,
     reporter: text+lcov, src/** included, tests/dist/index excluded).
   - Each vitest.config.ts imports + applies it. No thresholds enforced
     yet — this is baseline measurement. Per-package floors can be added
     once we see real numbers and decide where to start the ratchet.
   - @vitest/coverage-v8 added to root devDeps.
   - Root pnpm test:coverage script forwards --coverage through turbo.
   - coverage/ added to .gitignore.

The audit flagged 5 packages as "no tests" (ai-assistant, commands,
licensing, mcp-server, product-config). On inspection, only commands
actually had no tests — the other 4 already had test suites. So this PR
fills the one real gap and adds coverage infrastructure so future gaps
are visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes flagged by the audit, both in packages/mcp-server/src/index.ts:

1. server.tool() → server.registerTool() (MCP SDK 1.29 API).
   All 7 call sites migrated:
   - readied_list_notes
   - readied_read_note
   - readied_create_note
   - readied_update_note
   - readied_search_notes
   - readied_list_notebooks
   - readied_trash_note

   The old tool() overload is deprecated in @modelcontextprotocol/sdk
   (TS6387 noisy diagnostics in the IDE). registerTool takes a config
   object { description?, inputSchema?, outputSchema?, annotations?,
   _meta? } instead of positional args, so future evolution (output
   schemas, annotations) is additive.

2. read_note title search now uses FTS5 with bm25 ranking; LIKE is
   a fallback for empty-index cases (post-restore, fresh installs).
   The previous query did `title LIKE '%' || ? || '%'` which:
   - is slower than FTS5 (no index can be used on a wildcard-prefix LIKE),
   - and doesn't rank by relevance.

   New flow: build the FTS5 query via prepareFtsQuery() (same path
   used by readied_search_notes), bm25-rank, take the top hit. If
   FTS returns nothing (index rebuild in progress, freshly restored
   DB, etc.), fall back to the parameterized LIKE on the live table
   so the tool still works.

Build + 5 existing tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous restore handler trusted the backup file blindly: it copied
it over the live db, reopened it, ran migrations, and swapped the
reference. If the backup was corrupt, the user lost their working
database with no clear failure mode — they'd hit cryptic SQLite errors
on the next read.

New restore flow in apps/desktop/src/main/handlers/dataHandlers.ts:

1. Close current db.
2. copyFileSync(backup, live) via existing restoreBackup (this also
   writes a `.pre-restore` safety copy of the previous live db).
3. Open the restored db.
4. PRAGMA integrity_check — must return 'ok'.
5. Run migrations (no schema change here — they were already run
   before, but kept as belt-and-suspenders for older backups).
6. Swap the db reference and delete the safety copy.

Any failure between steps 3-5 rolls back to the safety copy and returns
a clear error message describing which step failed. The user is never
left with a corrupt or partially-migrated live database.

Audit refinements:

- B10 (missing index on notes.needs_sync) — already fixed in migration
  011_sync_tracking: `CREATE INDEX IF NOT EXISTS idx_notes_needs_sync
  ON notes(needs_sync) WHERE needs_sync = 1`. Auditor's note was based
  on stale snapshot. No change here.
- B11 (FTS5 triggers don't share an explicit txn) — false: SQLite
  triggers are atomic with their parent statement by default. If the
  trigger throws, the parent INSERT/UPDATE rolls back. No change here.
- B12 (backup restore has no integrity check) — addressed by this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scoped slice of PR-F. This adds the foundation; the rest of PR-F
(keychain, HMAC license) and the migration of the remaining 12 handler
modules become follow-up PRs once the pattern is reviewed.

What this PR introduces:

- apps/desktop/src/main/ipc/registry.ts — defineIpcHandler({channel, args, handler})
  wraps ipcMain.handle with Zod tuple validation. Renderer-supplied args
  are validated BEFORE business logic runs. An invalid payload throws
  IpcValidationError at the boundary, surfacing as a structured error
  to the renderer instead of a confusing downstream crash.
- apps/desktop/src/main/handlers/aiKeyHandlers.ts — migrated as the
  proof. Provider names are now constrained to alphanumeric (with _ or -),
  max 64 chars. API keys are bounded at 4096 chars. These are
  conservative defenses against accidental large payloads and obviously
  bogus inputs, not a substitute for the credential-at-rest hardening
  that's coming separately (keychain integration is the next slice of
  PR-F).
- apps/desktop/package.json — adds zod ^4.4.3 as a direct dep (it was
  already in 4 workspace packages but not exposed to desktop main).

Audit linkage:

- B2 (IPC handlers accept raw strings without validation): partially
  addressed for the AI-key surface. The 12 other handler modules will
  follow the same defineIpcHandler({channel, args, handler}) shape in
  subsequent PRs so each migration is small enough to review.
- B3 (AI keys stored unencrypted via keytar) and B4 (license HMAC) are
  deliberately NOT in this PR. They each need keytar integration with
  electron-builder, a one-time migration on first launch, and reviewer
  attention — separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-F4 — applies the typed IPC registry pattern from #272 to six handler
modules. ~30 channels validated at the boundary now.

Files:
- logHandlers.ts: log:write, log:getPath. LogLevel enum, message capped
  at 16 KiB, context schema is z.record(z.string(), z.unknown()).
- shareHandlers.ts: share:create, share:delete. Note content capped at
  1 MiB; tags/backlinks have count caps.
- updateHandlers.ts: updates:checkNow, updates:startDownload. The
  installNow handler is intentionally kept as raw ipcMain.handle —
  it triggers a synchronous quit + window destruction and we don't
  want the registry's async wrapper interfering with the shutdown path.
- licenseHandlers.ts: license:getState, refreshSubscription, startTrial,
  openSubscribe. Optional plan enum on openSubscribe.
- localServerHandlers.ts: localServer:start/stop/status/getToken.
  Port range bounded at 1–65535 by schema (was a defensive check
  inside the handler).
- pluginHandlers.ts: 11 channels — pluginConfig:get/set/getAll/clear,
  plugins:scan/isEnabled/setEnabled/listState/readInitScript/
  install/installFromUrl/uninstall. plugins:requestReload uses
  ipcMain.on (fire-and-forget, not invoke) and stays raw.

PluginId schema mirrors the regex enforced at install time
(/^[a-zA-Z0-9_-]+$/, max 128 chars) so the boundary catches the same
shape as the install-time check.

What did NOT change:
- The internal "https only" check in installFromUrl stays as defense
  in depth even though the schema's .startsWith('https://') already
  blocks it — clearer error message for the renderer.
- Auto-updater event listeners stay raw — they're not IPC, they're
  electron-updater event subscriptions.

PR-F5 (note, notebook, data, git, authSync — the heavy data CRUD
modules with ~96 handlers) is the next slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-F5 — completes the migration started in PR-F1 (#272) and continued in
PR-F4 (#273). Five remaining handler modules, ~97 channels, now run
through the typed IPC registry. The full IPC surface of the desktop app
is now validated at the boundary.

Files:
- notebookHandlers.ts (15 channels): notebooks CRUD + git-per-notebook
  settings. Common `serialize(nb)` helper extracted to avoid 6 copies
  of the same object spread.
- gitHandlers.ts (9 channels): all git ops bounded — short-SHA accepted
  (>=7 hex chars), commit message ≤8 KiB, note file content ≤1 MiB.
- dataHandlers.ts (7 of 8 channels): backup/list/export/exportNote/
  import/paths/openFolder migrated. `data:backup:restore` stays raw
  ipcMain.handle because the integrity-check + rollback state machine
  from PR-I doesn't fit the registry's async wrapper cleanly.
- noteHandlers.ts (32 channels): notes CRUD, tags, links, embeds, stats.
  EmbedTargetSchema is regex-constrained to filename-safe characters
  (no slashes, no path traversal) at the boundary in addition to the
  `join(dataPaths.assets, noteId, target)` guard. ArrayBuffer validated
  via z.instanceof(ArrayBuffer) for embeds:saveAsset.
- authSyncHandlers.ts (33 channels): auth/magic-link, sync (pull/push/
  syncNow/conflicts/auto-sync/history), E2EE key management (setup/
  unlock/migrate/changePassphrase/export/import), subscription portal,
  device list/rename/revoke. SyncChangeSchema is a discriminator-free
  z.object with the operation enum — it's the most complex schema in
  this PR.

After this PR every ipcMain.handle in apps/desktop/src/main/handlers/
either goes through defineIpcHandler with a Zod tuple guarding the args,
or has an explanatory comment for the few exceptions:
- `updates:installNow` (sync quit + window destruction path)
- `plugins:requestReload` (uses ipcMain.on — fire-and-forget, not invoke)
- `data:backup:restore` (integrity-check rollback state machine from #271)

Test plan:
- pnpm -r typecheck — green.
- pnpm test — 17/17 packages.
- Manual: exercise each affected flow for valid inputs; try a malformed
  payload via DevTools (e.g. window.readied.sync.push('not-an-array'))
  and expect an IpcValidationError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…errors

The audit listed B3 ("AI keys stored unencrypted") as a gap, but
aiKeyStorage.ts has been using Electron safeStorage since it was
introduced — keys are already OS-encrypted (Keychain / DPAPI / libsecret).

The real bug surfaced while reviewing this file: any failure in
safeStorage.decryptString() or JSON.parse() triggered clearAll() and
returned an empty map. On macOS, the keychain becomes temporarily
inaccessible after sleep/wake, which would silently delete the user's
stored AI keys on the next read. The keys were not actually corrupt —
they were simply unreadable for a few seconds.

New error contract:

- ENOENT on read       → return {}            (no keys yet, safe)
- isEncryptionAvailable() → false → throw AiKeyEncryptionUnavailableError
- safeStorage.decryptString throws → throw AiKeyDecryptionError
- JSON.parse fails               → throw AiKeyDecryptionError
- Top-level shape wrong          → throw AiKeyDecryptionError

The encrypted file is NEVER auto-deleted by a read path now. The only
delete sites are explicit removeKey() / clearAll-equivalent paths and
the case where the last key is removed.

Errors propagate to the IPC layer, where defineIpcHandler from #272
wraps them in a structured response for the renderer.

PR-F2 in the audit stack. PR-F3 (Ed25519 license verification — server
signs, client verifies with embedded public key, NOT HMAC) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the asymmetric verification primitive the desktop needs so the
license server can sign subscription state and the client can verify it
without holding any signing secret. No live wiring yet — those follow
once the server emits signed envelopes.

The wire format:

  {
    payload: {
      payloadVersion: 1,
      subscription: SubscriptionInfo,
      issuedAt: ISO8601,
      ttlSeconds?: number,
    },
    signature: base64(Ed25519(canonicalJson(payload), serverPrivateKey)),
  }

What this PR ships:

- `canonicalJson(value)` — deterministic sorted-key JSON used as the
  signed message. Client and server MUST canonicalize identically;
  JSON.stringify is non-deterministic across runtimes.
- `signSubscriptionPayload(payload, privateKeyHex)` — server-side helper
  using @noble/ed25519 (already a dep for the legacy LicenseFile path).
- `verifySubscriptionSignature(envelope, config)` — client-side check.
  Performs shape/version/signature/age checks plus the existing subscription
  field/expiry validation via verifySubscription. Injectable clock for tests.
- `SignedSubscriptionPayload` and `SignedSubscriptionEnvelope` types,
  exported through the package index.
- 18 new tests in __tests__/subscriptionSignature.test.ts covering
  roundtrip, key mismatch, tampering, version mismatch, replay window
  (both client-supplied and envelope-supplied TTL), future timestamps,
  and inactive subscriptions.
- packages/licensing/README.md documenting the wire format, the server
  signing flow, the embedded public key contract, key rotation, and
  why trial.json deliberately is NOT signed.

The DEFAULT_SUBSCRIPTION_PUBLIC_KEY placeholder is all zeros — calls
that fall back to it will fail until it's replaced with the real
server public key. Every internal consumer passes config.publicKey
explicitly, so the placeholder is a footgun only for new naive callers.

Audit linkage:
- B4 (license files unsigned, "trivial tamper") — addressed for the
  subscription path. HMAC was rejected as you noted: a client that
  can verify a symmetric MAC can also forge it. Ed25519 is correct.
- Trial state remains unsigned by design — see README "What is NOT
  signed".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the infrastructure for end-to-end testing the desktop app, plus
two initial specs and a Linux CI job. The CI job is marked
continue-on-error: true while we stabilize the suite — flip the flag off
in a follow-up once it's reliably green on develop.

Setup:
- apps/desktop/playwright.config.ts: serial workers (Electron app
  state is shared), generous timeouts, retain-on-failure trace/video.
- apps/desktop/e2e/fixtures.ts: launchApp() helper that spins up a
  fresh Electron instance with an isolated userData directory per
  test, so the SQLite DB, settings, license cache, etc. don't leak
  between runs. Uses Playwright's _electron.launch.
- apps/desktop/e2e/tsconfig.json: dedicated tsconfig so the specs
  don't pull in renderer/main types they don't need.
- apps/desktop/package.json: adds @playwright/test devDep, e2e +
  e2e:headed scripts, typecheck:e2e step folded into typecheck.
- apps/desktop/e2e/README.md: how to run locally, what's tested,
  what's deliberately out of scope.

Specs:
- smoke.spec.ts: app launches, main window renders, IPC bridge is
  present, no uncaught console errors during initial mount. This is
  the regression catch for #266 (editor mount crashes that produced
  blank windows).
- notes.spec.ts: notes IPC contract — create / list / get roundtrip,
  FTS5 search returns freshly-created notes. We drive the preload
  bridge (window.readied.notes.*) rather than the editor UI on
  purpose: selectors churn, contracts are stable.

CI:
- .github/workflows/ci.yml: new `e2e` job, ubuntu-latest, xvfb,
  uploads playwright-report artifact on failure. continue-on-error:
  true initially.
- .gitignore: excludes test-results/, playwright-report/,
  playwright/.cache/
- knip.json: registers playwright.config.ts + e2e/**/*.ts so they
  don't show up as unused.

What is deliberately NOT in this PR:
- Editor UI interactions (typing, formatting, hotkeys) — CodeMirror
  surface is too prone to flake; revisit once the editor is split.
- AI panel streaming — better as a vitest test against ai-core.
- Sync flows — need a fake server.

I scaffolded all of this but could not execute the suite end-to-end
against a real Electron build in this session (no display attached,
no built bundle in this branch). The CI's continue-on-error gate and
the local README acknowledge that the first verifier on a real machine
may need to tweak the specs once they meet a real renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First scoped slice of the PR-G "split god files" effort.

apps/desktop/src/main/index.ts goes from 1065 -> 950 lines (-11%).
No behavior change. All extractions are self-contained and surface-area
preserving:

- services/fileLicenseStorage.ts — was embedded in index.ts at lines
  146-216. The three readJsonOrNull helpers fold the repeated
  try/catch/return-null pattern into one private function. The TODO
  about signed subscription envelopes points back at PR-F3 so the
  next step is unambiguous.

- services/windowState.ts — was at lines 218-254 of index.ts.
  Comment now explicitly documents why the file I/O is sync (called
  during BrowserWindow construction before the renderer mounts and
  during close where handlers don't await).

What this PR DELIBERATELY does NOT touch:

- packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts
  (1121 lines). Splitting this into NoteCrudRepository +
  NoteTagRepository + NoteArchiveRepository + NoteSyncRepository
  needs runtime verification against a real DB and risks merge
  conflicts with every other PR in flight. Separate effort.
- apps/desktop/src/renderer/components/MarkdownEditor.tsx (724
  lines). The CodeMirror surface area is too entangled to split
  without an E2E suite to catch regressions. Defer until PR-E
  (#277) is stabilized and we can refactor under test coverage.

Validates:
- pnpm -r typecheck — green
- pnpm test — 17/17 packages
- pnpm --filter @readied/desktop typecheck — green (includes e2e
  tsconfig)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Knip surfaced 28 unused files in #267. After per-file verification,
12 were genuinely orphaned. Deleting them:

apps/desktop:
- src/renderer/analytics.ts          — no imports anywhere
- src/renderer/hooks/useTheme.ts     — no imports anywhere
- src/renderer/settings.tsx          — no imports anywhere
- src/renderer/ui/patterns/Modal.tsx — only its sibling index.ts
- src/renderer/ui/patterns/index.ts  — barrel that nothing imports
- src/renderer/ui/patterns/Modal.module.css + .gitkeep — left over

packages:
- plugin-api/src/editor/types.ts     — not re-exported by index.ts
- storage-core/src/interfaces/index.ts \
- storage-core/src/migrations/index.ts  \
- storage-core/src/repositories/index.ts \  — main package index imports
- storage-core/src/types/index.ts        /   directly from concrete files,
- storage-sqlite/src/repositories/index.ts/   not these barrels

scripts:
- scripts/bump-version.js            — not in any package.json script
                                       and not referenced by any CI workflow

Kept (Knip false positives — auto-discovered files Knip can't see):
- vite-env.d.ts                      (vite reference declaration)
- css-modules.d.ts                   (TypeScript module shim)
- turndown-plugin-gfm.d.ts           (TypeScript module shim)
- apps/web/mdx-components.tsx        (Next.js MDX convention, no import)

Kept (Knip wrong — barrel that IS used):
- renderer/pages/settings/components/controls/{NumberInput,Select,TextInput,Toggle}.tsx
  All four are imported by EditorSection / AiSection / UpdatesSection
  through the controls/index.ts barrel.

Skipped (out of audit scope):
- apps/web/components/magicui/*, NavDropdown, ui/separator

Validates:
- pnpm -r typecheck — green
- pnpm test — 17/17 packages, all cached except plugin-api which ran
  fresh after the editor/types.ts deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Knip flagged 11 unused dependencies in #267. After per-dep verification
(grep + transitive-dep audit), 6 are genuinely unused in scope. Removing
them shrinks the lockfile by ~120 lines.

apps/desktop production deps removed:
- highlight.js          — rehype-highlight uses lowlight, not highlight.js
- pino-roll             — logger.ts uses createWriteStream directly, no roll
- react-resizable-panels — no imports anywhere
- unist-util-visit      — no imports anywhere

apps/desktop dev deps removed:
- @types/mdast          — no imports anywhere
- pino-pretty           — logger.ts doesn't pipe through a transport

packages/sync-core: drop @readied/core dependency
- src/* has no imports of @readied/core; the workspace link was stale

packages/wikilinks: drop unified
- src/* uses unist-util-visit directly (kept) but never imports unified

Skipped (apps/web — out of audit scope):
- @radix-ui/react-separator
- next-themes
- tailwindcss-animate

Validates:
- pnpm install --ignore-scripts — succeeds; pre-existing peer warnings
  about electron-builder + electron-vite are unrelated
- pnpm -r typecheck — green
- pnpm test — 17/17 packages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ge layer

Wires the verification primitive added in #276 into the desktop's
FileLicenseStorage. The path from server → disk → in-memory now has a
real verification step at read time, with lenient fallthrough during the
migration window.

Changes:

1. packages/licensing/src/types.ts
   - StoredSubscriptionData now allows an optional `signedEnvelope`
     alongside the existing `subscription` field. Both can be present
     during migration; long-term the unsigned `subscription` becomes
     a derived view of the envelope payload.

2. apps/desktop/src/main/services/fileLicenseStorage.ts
   - readSubscriptionData now branches on envelope presence:
     - envelope present + signature valid → return cache as-is
     - envelope present + signature invalid → log error, REFUSE the
       cache (return null). Next caller fetches fresh from the API.
     - envelope absent → log warning, accept the cache (lenient
       migration mode)
   - SUBSCRIPTION_PUBLIC_KEY constant added at the top of the file
     with a REPLACE BEFORE SHIPPING note. The all-zeros placeholder
     means any real envelope will fail verification — which is the
     correct failure mode while the placeholder is in place: we fall
     through to the "no envelope" branch and the lenient log fires.

What's NOT in this PR (still pending the server team):
- ApiClient.getSubscriptionStatus does not yet return an envelope.
  When it does, mapApiToSubscriptionData in licenseHandlers.ts grows
  one more field (signedEnvelope) and writeSubscriptionData persists
  it. That change is one-liner plumbing, blocked only on the API
  contract being updated.
- The placeholder public key gets swapped for the real server key in
  the release that first ships signed envelopes.

Validates:
- pnpm -r typecheck — green
- pnpm test — 17/17 packages (the 18 signature tests added in #276 cover
  the verify path; the storage layer just calls into them)

Behavior verification (manual):
- Empty subscription.json → null returned, no warning (normal cold start)
- subscription.json with old shape (no envelope) → warning logged,
  cache accepted (existing users keep working)
- subscription.json with a forged or stale envelope → error logged,
  cache refused, API refetch happens
- subscription.json with a real envelope signed by the matching server
  key → silent acceptance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Repository

Phase 1 of the SQLiteNoteRepository split. Pure helpers come out to
their own file so future sub-repos (sync, tag, archive) can reuse them
without inheriting from the 1000-line class.

SQLiteNoteRepository.ts: 1121 -> 1038 lines (-7%).
noteMapping.ts: new (133 lines).

Extracted:
- NoteRow / TagRow / TagWithColorRow / BacklinkInfo  — row types
- rowToNote(row, tags) -> Note                       — pure mapper
- prepareFtsQuery(input) -> string                   — FTS5 query escaper
- archivedConditionSql(filter, alias) -> string      — SQL fragment helper

Call sites swapped from `this.<helper>()` to plain function imports.
The public class signature is unchanged; `BacklinkInfo` is re-exported
so external consumers (apps/desktop/handlers/types.ts) keep working.

What this PR DELIBERATELY does NOT do:

- Extract sync methods (getPendingChanges through getSyncHistory ~430
  lines) into a SQLiteNoteSyncRepository. Those share state (tag
  queries, transactions, FTS sync triggers) with the main class in
  ways that need real-DB integration coverage to refactor safely. The
  helpers extracted here are the foundation: a follow-up PR can build
  the sync sub-repo on top of them without touching the helpers again.
- Extract tag methods (setManualTags, renameTag, getAllTagsWithColors,
  etc.) for the same reason.

Validates:
- pnpm -r typecheck — green
- pnpm test — 17/17 packages (storage-sqlite tests skipped per repo
  policy; helpers are pure and trivially exercised by the desktop's
  integration tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the MarkdownEditor split. Pure CSS-variable-based theme and
markdown HighlightStyle move to their own file so further extractions
(extensions array, keymap bindings) can ride on top without merging
against a churn-prone component shell.

MarkdownEditor.tsx: 737 -> 612 lines (-17%).
editorTheme.ts: new (139 lines).

Extracted:
- SCROLL_PAST_END_PADDING constant
- createEditorTheme(fontSize, fontFamily, lineHeight) — EditorView.theme(...)
- markdownHighlighting — HighlightStyle.define([...]) for markdown tags

Imports cleaned up:
- Drop the explicit HighlightStyle import from @codemirror/language (still
  consumed transitively, but not referenced in this file anymore)
- Drop the `tags` import from @lezer/highlight (moved to editorTheme.ts)

What this PR DELIBERATELY does NOT do:
- Extract the extensions array (lines 348-444 today). The array closes
  over user settings (lineNumbersCompartment etc.) and mixes
  context-coupled values like `wikilinkAutocomplete` from a hook; safely
  pulling that out requires either passing the closure context in via a
  builder, or moving the whole `useMemo` into a hook of its own. Better
  done under Playwright coverage (PR-E #277) so renderer regressions
  surface.
- Extract the keymap. Same reason — the bindings reference
  view-imperatives + the command-registry which are constructed inside
  the React tree.

Validates:
- pnpm -r typecheck — green (renderer + e2e tsconfigs)
- pnpm test — 17/17 packages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the all-zeros placeholder from #281 with the actual Ed25519
public key. The matching private key lives only on the licensing
server — never the repo.

Behavior change:
- Before: any real signed envelope failed verification (placeholder
  doesn't match any private key), so the lenient "no envelope" branch
  was the only working path.
- After: envelopes signed by the matching private key verify and pass
  through; forged or tampered envelopes are refused and the cache is
  re-fetched on next read.

The lenient branch (no envelope on disk → warning log + accept) is
unchanged. Existing users without a cached envelope are unaffected.

Rotation procedure now lives in the constant's doc comment — generate
keypair on a trusted machine, ship desktop release with new public
key, then flip the server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the server-side counterpart of the public key set in the previous
commit. The api worker needs LICENSE_SIGNING_PRIVATE_KEY in its
Cloudflare secrets to sign SignedSubscriptionEnvelope payloads via
@readied/licensing#signSubscriptionPayload.

What this commit adds:
- packages/api/.dev.vars: placeholder line + generation snippet so
  contributors know what the env var is, where the matching public
  key lives, and how to mint a fresh pair. The .dev.vars file is
  tracked (it's a template); real values go in .dev.vars.local
  (gitignored).
- packages/api/wrangler.toml: adds LICENSE_SIGNING_PRIVATE_KEY to the
  "Required secrets" comment block alongside the existing TURSO/JWT/
  RESEND/STRIPE entries.

What this commit does NOT do:
- Actually set the secret in Cloudflare. That requires `wrangler login`
  in an authenticated session, which has to happen on a maintainer's
  machine. Run:
    cd packages/api
    echo "<hex>" | npx wrangler secret put LICENSE_SIGNING_PRIVATE_KEY --env production
    echo "<hex>" | npx wrangler secret put LICENSE_SIGNING_PRIVATE_KEY --env staging
  See PR #284 description for the matching public key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
readide Error Error Jun 9, 2026 2:46am

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tomymaritano, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 38 minutes and 4 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f2e9f023-8aac-45d7-a841-8123375e7de3

📥 Commits

Reviewing files that changed from the base of the PR and between 390503c and b668828.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • apps/desktop/package.json
  • package.json
  • packages/wikilinks/package.json
📝 Walkthrough

Walkthrough

This PR refactors Electron IPC handlers to enforce runtime argument validation using Zod at the IPC boundary, adds Playwright E2E testing infrastructure for the desktop app, implements Ed25519-based subscription envelope signing and verification, extracts storage layer helpers to reduce duplication, and consolidates development tooling configuration while removing unused renderer modules.

Changes

IPC Handler Refactoring with Zod Validation

Layer / File(s) Summary
IPC Registry and Validation Infrastructure
apps/desktop/src/main/ipc/registry.ts
New defineIpcHandler pattern with DefineIpcHandlerConfig interface, IpcValidationError class, and handler registration function that validates arguments against Zod tuple schemas before invoking business logic.
Core Handler Migrations
apps/desktop/src/main/handlers/{aiKeyHandlers, licenseHandlers, localServerHandlers, logHandlers}.ts
Migrates AI key, license, local server, and logging IPC handlers to defineIpcHandler with Zod schemas for provider names, API keys, port ranges, log levels, and paths.
Complex Handler Migrations
apps/desktop/src/main/handlers/{authSyncHandlers, dataHandlers, gitHandlers}.ts
Refactors authentication, sync, and data backup/restore handlers with comprehensive Zod validation for emails, tokens, passphrases, encryption keys, sync payloads, and database operations including integrity checking and rollback safety on restore failure.
Rich Domain Handler Migrations
apps/desktop/src/main/handlers/{noteHandlers, notebookHandlers, pluginHandlers, shareHandlers, updateHandlers}.ts
Migrates note CRUD/search, notebook tree, plugin discovery/installation, share, and auto-update handlers to defineIpcHandler with extensive Zod schemas for notes, notebooks, plugins, and URLs.
Service Extraction
apps/desktop/src/main/services/{aiKeyStorage, fileLicenseStorage, windowState}.ts, apps/desktop/src/main/index.ts
Extracts FileLicenseStorage and WindowState persistence from main entrypoint; updates AiKeyStorage to use typed errors (AiKeyEncryptionUnavailableError, AiKeyDecryptionError) without deleting encrypted data on failure; refactors main entrypoint to import services.

E2E Testing Infrastructure

Layer / File(s) Summary
E2E Test Fixtures and Configuration
apps/desktop/e2e/{fixtures.ts, tsconfig.json}, apps/desktop/playwright.config.ts
Implements launchApp() fixture creating isolated test environments with temp directories and test-specific env vars; adds Playwright configuration for serial execution with CI-aware retries and artifact retention on failure.
E2E Test Specs
apps/desktop/e2e/{smoke.spec.ts, notes.spec.ts}
Adds smoke tests validating app launch, window rendering, and IPC bridge presence; adds notes IPC contract tests validating create/list/read and FTS search operations.
E2E Documentation and CI Integration
apps/desktop/e2e/README.md, .github/workflows/ci.yml, .gitignore
Adds E2E README documenting setup and test isolation; updates CI workflow to install Playwright deps, build desktop bundle, run E2E under xvfb-run, and upload artifacts on failure; updates .gitignore for test artifacts.

Subscription Signing and Verification

Layer / File(s) Summary
Canonical JSON and Signing/Verification Functions
packages/licensing/src/validator.ts
Introduces canonicalJson() for deterministic JSON serialization, signSubscriptionPayload() to sign with Ed25519 private keys, and verifySubscriptionSignature() to validate envelopes with replay/age/clock-skew checks.
Subscription Types and File-Based License Storage
packages/licensing/src/types.ts, apps/desktop/src/main/services/fileLicenseStorage.ts
Adds SignedSubscriptionPayload and SignedSubscriptionEnvelope types; implements FileLicenseStorage that persists license/trial/subscription data to JSON files with signature verification and lenient migration mode.
Subscription Signing Tests and Configuration
packages/licensing/__tests__/subscriptionSignature.test.ts, packages/licensing/{src/index.ts, tsconfig.json}, packages/api/{.dev.vars, wrangler.toml}
Adds comprehensive test suite for signing/verification; updates exports and configuration; adds signing key placeholders to .dev.vars and .wrangler.toml.

Storage Layer Refactoring

Layer / File(s) Summary
Note Mapping Module Creation
packages/storage-sqlite/src/repositories/noteMapping.ts
Introduces shared module with NoteRow, TagRow, TagWithColorRow, BacklinkInfo interfaces; exports rowToNote() to convert SQLite rows to domain objects, prepareFtsQuery() for FTS5 MATCH clauses, and archivedConditionSql() for archived-filter SQL fragments.
Repository Updates Using Shared Helpers
packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts
Refactors repository to import and use mapping helpers from noteMapping, removing local implementations and delegating BacklinkInfo re-export.
Barrel Export Cleanup
packages/storage-core/src/{interfaces, migrations, repositories, types}/index.ts, packages/storage-sqlite/src/repositories/index.ts
Removes re-exports from storage package index files to reduce public API surface.
MCP Server FTS5 Search Update
packages/mcp-server/src/index.ts
Updates tool registration to use server.registerTool() with explicit Zod inputSchema; changes note title lookup to FTS5-based search with fallback to LIKE query.

Development Tooling and Code Cleanup

Layer / File(s) Summary
Git Hooks Migration (Husky → Lefthook)
lefthook.yml, .husky/{commit-msg, pre-commit, pre-push}, package.json
Removes Husky-based hooks and replaces with lefthook.yml configuration; updates package.json to remove Husky setup script and add lefthook as dev dependency.
Test Coverage Configuration Consolidation
vitest.shared.ts, {apps/desktop, packages/*}/vitest.config.ts, package.json
Creates unified vitest.shared.ts with coverage configuration; updates all package vitest configs to import sharedCoverage; adds test:coverage script to root package.json.
Linting, Knip, and Build Tooling Updates
knip.json, package.json, tsconfig.base.json, apps/desktop/src/renderer/tsconfig.json, scripts/bump-version.js
Adds knip.json for dead-code detection; updates lint-staged to use ESLint for TypeScript; removes bump-version.js script; updates tsconfig files.
Renderer Module Cleanup and Code Reorganization
apps/desktop/src/renderer/{analytics.ts, hooks/useTheme.ts, settings.tsx, ui/patterns/Modal.*}, apps/desktop/src/renderer/components/{editorTheme.ts, MarkdownEditor.tsx, tables.tsx, auth/*.tsx, sync/EnableSyncModal.tsx, settings/sections/AccountSection.tsx}
Removes unused modules (analytics, useTheme, settings entrypoint, Modal component); extracts CodeMirror editor theming to editorTheme.ts; adopts store selector pattern in components; migrates table widget from ViewPlugin to StateField.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • tomymaritano/readide#279: Removes the same unused desktop renderer modules (analytics, useTheme, Modal, settings) and their index re-exports.
  • tomymaritano/readide#274: Implements the same defineIpcHandler + Zod IPC-handler refactoring pattern across handler modules.
  • tomymaritano/readide#172: Introduces AI provider key storage and IPC endpoints that are now refactored with Zod validation in this PR.

Suggested labels

dependencies, ci, size/XL, app:desktop, package:api, package:storage

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/set-subscription-public-key

…tion-public-key

# Conflicts:
#	apps/desktop/package.json
#	package.json
#	pnpm-lock.yaml
@github-actions github-actions Bot enabled auto-merge (squash) June 9, 2026 02:44
@github-actions github-actions Bot added size/XL dependencies Pull requests that update a dependency file ci package:plugin-api and removed size/XL labels Jun 9, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/mcp-server/src/index.ts (1)

42-47: 🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting prepareFtsQuery to eliminate duplication.

This function is duplicated in packages/storage-sqlite/src/repositories/noteMapping.ts (lines 105-110). If the FTS5 query preparation logic needs to change, both locations must be updated. Consider extracting to a shared utility package.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mcp-server/src/index.ts` around lines 42 - 47, The prepareFtsQuery
implementation is duplicated; extract it into a shared utility module and update
callers to import it instead of having two copies. Create a new exported
function named prepareFtsQuery in a common/shared package (e.g., a utils or
common lib) that contains the existing logic, then replace the local
implementations in packages/mcp-server/src/index.ts and
packages/storage-sqlite/src/repositories/noteMapping.ts with imports from that
shared module (update import paths and remove the local function definitions),
and run type-check/build to ensure no import or export errors.
apps/desktop/src/main/handlers/licenseHandlers.ts (1)

184-186: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove raw email addresses from the checkout logs.

These info logs persist the subscriber's email on every checkout attempt/open. That's unnecessary PII in a desktop log file and creates retention/compliance risk without helping the flow. Log the plan and a correlation id instead, or omit the user field entirely.

Also applies to: 201-201

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/handlers/licenseHandlers.ts` around lines 184 - 186,
The log call using getLicenseLogger().info currently includes raw user.email;
remove the PII by deleting user.email from the logged object and instead log
only non-PII like options?.plan (or 'monthly') plus a correlation id (e.g.,
correlationId or checkoutId) passed into the handler, or omit the user field
entirely; update both occurrences that reference getLicenseLogger().info and
user.email so logs no longer record the subscriber's email.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/e2e/fixtures.ts`:
- Around line 30-65: launchApp currently creates userDataDir via mkdtemp but if
electron.launch() or app.firstWindow() throws the temp dir is orphaned; wrap the
launch/firstWindow sequence in a try/catch and on any thrown error remove the
created userDataDir (using rm(userDataDir, { recursive: true, force: true }))
unless process.env.READIED_E2E_KEEP_USERDATA === '1', then rethrow the error;
reference the launchApp function, mkdtemp call that sets userDataDir, the
electron.launch and app.firstWindow calls, and preserve the existing cleanup
closure for successful returns.

In `@apps/desktop/e2e/smoke.spec.ts`:
- Around line 47-51: The current broad filters in the ignored array (used by
consoleErrors -> real) hide any message containing "[Sentry]" or "Failed to load
resource: net::ERR_", which can silence real errors; narrow these to exact known
noise by replacing those regexes with anchored patterns or exact string matches
that match the precise noise you expect (e.g., an anchored regex for
"^[\\[Sentry\\]]\\s*No DSN configured\\b" or a full exact string for "Failed to
load resource: net::ERR_NAME_NOT_RESOLVED" or similar), update the ignored array
and keep the filter using consoleErrors and real unchanged so only the specific
expected benign messages are filtered out.

In `@apps/desktop/src/main/handlers/authSyncHandlers.ts`:
- Around line 517-523: The handler registered with defineIpcHandler on channel
'subscription:openPortal' currently accepts a raw UrlSchema and forwards it into
client.createPortalSession, which allows the renderer to control the post-portal
redirect; instead, restrict return URLs in main by replacing the UrlSchema
argument with a fixed enum or string key (e.g., 'dashboard' | 'billing' etc.),
maintain an internal whitelist/map of allowed return URLs in this module, look
up the corresponding safe URL before calling client.createPortalSession, and
reject/unhandled any unmapped value (throw or return an error). Update the
handler signature and use the enum/key-to-URL mapping when calling
client.createPortalSession and then shell.openExternal(url) so no unvalidated
URL from the renderer is ever forwarded.

In `@apps/desktop/src/main/handlers/dataHandlers.ts`:
- Around line 55-60: The handler registered via
ipcMain.handle('data:backup:restore', which currently accepts a
renderer-supplied backupPath and then calls restoreBackup(), must validate
ownership before touching the DB: either move the file-picker into the main
process or reject any backupPath that is not inside the application's configured
backup directory and that does not match the expected filename pattern;
implement a check using the app's backup directory (e.g., resolve
app.getPath('userData')/backups or the existing BACKUP_DIR constant) and
canonicalize the provided path with path.resolve and path.relative to ensure it
starts with the backup directory, and if validation fails throw/reject before
calling restoreBackup(); update the handler around
ipcMain.handle('data:backup:restore', backupPath) and the call site of
restoreBackup() accordingly.

In `@apps/desktop/src/main/handlers/noteHandlers.ts`:
- Around line 318-325: The handler registered via defineIpcHandler for channel
'embeds:resolve' currently joins an unvalidated noteId into dataPaths.assets and
can be path-traversed; fix it by reusing the same strict note-id
regex/validation used by the 'embeds:saveAsset' code path to validate the
incoming IdSchema value before using join, then normalize the resolved path
(path.resolve) and assert the normalized path starts with the resolved
dataPaths.assets root as a second defense; if validation or the root-check
fails, return null (or reject) instead of calling existsSync and constructing an
asset:// URL. Ensure you apply the same validation/root-check logic to the other
embeds handlers that join noteId into dataPaths.assets.
- Around line 179-206: The reviewer claim that the fixed-length tuple schemas
will reject current preload callers is incorrect; do not change the schemas in
defineIpcHandler for channel 'notes:list' and 'notes:search' — the tuple element
types are already optional (the options object in the 'notes:list' tuple and the
second limit arg in 'notes:search'), so callers passing undefined are accepted
by repo.list and repo.search and the noteToSnapshot mapping; revert any schema
relaxations or new changes and instead add a one-line comment by the
defineIpcHandler calls (channels 'notes:list' and 'notes:search') clarifying
that preload callers pass undefined as positional args rather than omitting
them, so the current z.tuple([...optional()...]) schemas are correct.

In `@apps/desktop/src/main/handlers/pluginHandlers.ts`:
- Around line 23-30: The uninstall flow is normalizing IDs differently than
install, causing mismatched paths for IDs like My_Plugin; update the
"plugins:uninstall" IPC handler to use the already-validated pluginId (the value
that passed PluginIdSchema) when constructing the install directory instead of
applying the ad-hoc lowercase/strip normalization, or alternatively apply the
same normalization at install time so both install and uninstall use the same
directory-name logic; locate the uninstall handler ("plugins:uninstall") and the
code that builds the plugin path (the logic that strips/lowers characters) and
either remove that normalization and use pluginId directly or mirror that
normalization in the install path builder so directory names are consistent.

In `@apps/desktop/src/main/services/fileLicenseStorage.ts`:
- Line 51: Rotate the embedded SUBSCRIPTION_PUBLIC_KEY before production by
generating a new keypair (use the generateKeyPair utility described in
packages/licensing/README.md), replace the constant SUBSCRIPTION_PUBLIC_KEY in
fileLicenseStorage.ts with the new public key string, and ensure you commit the
updated public key so the next release ships with the rotated key; follow the
README steps to verify the private key is used only where intended and not
committed.

In `@knip.json`:
- Line 25: The glob entry "e2e/**/*.{ts,spec.ts}" is redundant; update the
pattern to a single-extension form such as "e2e/**/*.ts" (replace the existing
"e2e/**/*.{ts,spec.ts}" string) so .ts already covers .spec.ts files and
simplifies the configuration.

In `@lefthook.yml`:
- Around line 5-10: Remove the redundant parallel configuration from the
pre-commit hook: edit the pre-commit block where "parallel: true" is set and
delete that key so the hook contains only the "commands" (e.g., the
"lint-staged" command). Keep the "lint-staged" command and its settings (run:
pnpm lint-staged, stage_fixed: true) intact; no other changes required.

In `@packages/commands/src/markdown/commands.test.ts`:
- Around line 116-126: The tests for insertCodeBlock and insertHorizontalRule
are too loose — replace the toContain assertions with regex-based matches that
assert proper Markdown structure: for insertCodeBlock (in the test using
fakeView and t.doc()) assert the document matches a fenced code block pattern
like /```[\s\S]*```/; for insertHorizontalRule assert the document contains a
standalone HR line using a pattern like /(^|\n)---(\n|$)/ so partial substrings
don't produce false positives.

In `@packages/licensing/src/validator.ts`:
- Around line 19-31: The code currently provides DEFAULT_SUBSCRIPTION_PUBLIC_KEY
(all-zeros) as a silent fallback when config?.publicKey is omitted; change
validator.ts so production cannot accidentally use that placeholder by removing
the silent fallback and requiring an explicit public key: update
verifySubscriptionSignature to throw a clear error if config?.publicKey is
missing (or if it equals DEFAULT_SUBSCRIPTION_PUBLIC_KEY) unless running in a
safe test/dev mode (e.g., NODE_ENV === 'test' or an explicit allowlist), and
consider making DEFAULT_SUBSCRIPTION_PUBLIC_KEY undefined or inaccessible so
callers must pass a real key; reference DEFAULT_SUBSCRIPTION_PUBLIC_KEY and the
verifySubscriptionSignature path that currently reads config?.publicKey to
implement this guard.

In `@packages/storage-sqlite/src/repositories/noteMapping.ts`:
- Around line 62-91: In rowToNote, remove the redundant reassignments of
notebookId, title, isPinned, isDeleted, and status in the returned object since
createNote already sets them; keep and set only the metadata overrides
(metadata.title, metadata.createdAt, metadata.updatedAt, metadata.tags,
metadata.wordCount, metadata.archivedAt) along with spreading ...note to
preserve defaults. Update the return to be simply { ...note, metadata: {
...note.metadata, title: row.title, createdAt: row.created_at as Timestamp,
updatedAt: row.updated_at as Timestamp, tags, wordCount: row.word_count,
archivedAt: row.archived_at as Timestamp | null } } so rowToNote, createNote,
and metadata fields remain correct and non-redundant.

In `@vitest.shared.ts`:
- Line 21: The coverage exclusion '**/index.{ts,tsx}' currently hides all index
files; inspect index files across the repo and either (A) keep the exclusion if
they are strictly re-export barrels and add a clarifying comment near the
exclusion, or (B) remove or tighten the exclusion in the vitest.shared.ts
coverage config (e.g., delete the '**/index.{ts,tsx}' entry or replace it with a
narrower glob targeting known barrel directories) so index files that contain
business logic or helpers are included in coverage.

---

Outside diff comments:
In `@apps/desktop/src/main/handlers/licenseHandlers.ts`:
- Around line 184-186: The log call using getLicenseLogger().info currently
includes raw user.email; remove the PII by deleting user.email from the logged
object and instead log only non-PII like options?.plan (or 'monthly') plus a
correlation id (e.g., correlationId or checkoutId) passed into the handler, or
omit the user field entirely; update both occurrences that reference
getLicenseLogger().info and user.email so logs no longer record the subscriber's
email.

In `@packages/mcp-server/src/index.ts`:
- Around line 42-47: The prepareFtsQuery implementation is duplicated; extract
it into a shared utility module and update callers to import it instead of
having two copies. Create a new exported function named prepareFtsQuery in a
common/shared package (e.g., a utils or common lib) that contains the existing
logic, then replace the local implementations in
packages/mcp-server/src/index.ts and
packages/storage-sqlite/src/repositories/noteMapping.ts with imports from that
shared module (update import paths and remove the local function definitions),
and run type-check/build to ensure no import or export errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d38fca3b-4faf-42dc-a38c-518b6aec7691

📥 Commits

Reviewing files that changed from the base of the PR and between fb987c1 and 390503c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (81)
  • .github/workflows/ci.yml
  • .gitignore
  • .husky/commit-msg
  • .husky/pre-commit
  • .husky/pre-push
  • apps/desktop/e2e/README.md
  • apps/desktop/e2e/fixtures.ts
  • apps/desktop/e2e/notes.spec.ts
  • apps/desktop/e2e/smoke.spec.ts
  • apps/desktop/e2e/tsconfig.json
  • apps/desktop/package.json
  • apps/desktop/playwright.config.ts
  • apps/desktop/src/main/handlers/aiKeyHandlers.ts
  • apps/desktop/src/main/handlers/authSyncHandlers.ts
  • apps/desktop/src/main/handlers/dataHandlers.ts
  • apps/desktop/src/main/handlers/gitHandlers.ts
  • apps/desktop/src/main/handlers/licenseHandlers.ts
  • apps/desktop/src/main/handlers/localServerHandlers.ts
  • apps/desktop/src/main/handlers/logHandlers.ts
  • apps/desktop/src/main/handlers/noteHandlers.ts
  • apps/desktop/src/main/handlers/notebookHandlers.ts
  • apps/desktop/src/main/handlers/pluginHandlers.ts
  • apps/desktop/src/main/handlers/shareHandlers.ts
  • apps/desktop/src/main/handlers/updateHandlers.ts
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/ipc/registry.ts
  • apps/desktop/src/main/services/aiKeyStorage.ts
  • apps/desktop/src/main/services/fileLicenseStorage.ts
  • apps/desktop/src/main/services/windowState.ts
  • apps/desktop/src/renderer/analytics.ts
  • apps/desktop/src/renderer/components/MarkdownEditor.tsx
  • apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx
  • apps/desktop/src/renderer/components/editorTheme.ts
  • apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx
  • apps/desktop/src/renderer/hooks/useTheme.ts
  • apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx
  • apps/desktop/src/renderer/plugins/tables.tsx
  • apps/desktop/src/renderer/settings.tsx
  • apps/desktop/src/renderer/tsconfig.json
  • apps/desktop/src/renderer/ui/patterns/.gitkeep
  • apps/desktop/src/renderer/ui/patterns/Modal.module.css
  • apps/desktop/src/renderer/ui/patterns/Modal.tsx
  • apps/desktop/src/renderer/ui/patterns/index.ts
  • apps/desktop/vitest.config.ts
  • knip.json
  • lefthook.yml
  • package.json
  • packages/api/.dev.vars
  • packages/api/vitest.config.ts
  • packages/api/wrangler.toml
  • packages/commands/src/markdown/commands.test.ts
  • packages/commands/vitest.config.ts
  • packages/core/vitest.config.ts
  • packages/embeds/vitest.config.ts
  • packages/licensing/README.md
  • packages/licensing/__tests__/subscriptionSignature.test.ts
  • packages/licensing/src/index.ts
  • packages/licensing/src/types.ts
  • packages/licensing/src/validator.ts
  • packages/licensing/tsconfig.json
  • packages/licensing/vitest.config.ts
  • packages/mcp-server/src/index.ts
  • packages/plugin-api/src/editor/types.ts
  • packages/plugin-api/vitest.config.ts
  • packages/product-config/vitest.config.ts
  • packages/storage-core/src/interfaces/index.ts
  • packages/storage-core/src/migrations/index.ts
  • packages/storage-core/src/repositories/index.ts
  • packages/storage-core/src/types/index.ts
  • packages/storage-core/vitest.config.ts
  • packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts
  • packages/storage-sqlite/src/repositories/index.ts
  • packages/storage-sqlite/src/repositories/noteMapping.ts
  • packages/storage-sqlite/vitest.config.ts
  • packages/sync-core/package.json
  • packages/tasks/vitest.config.ts
  • packages/wikilinks/package.json
  • packages/wikilinks/vitest.config.ts
  • scripts/bump-version.js
  • tsconfig.base.json
  • vitest.shared.ts
💤 Files with no reviewable changes (18)
  • packages/plugin-api/src/editor/types.ts
  • packages/storage-core/src/types/index.ts
  • apps/desktop/src/renderer/settings.tsx
  • apps/desktop/src/renderer/ui/patterns/index.ts
  • packages/storage-core/src/interfaces/index.ts
  • packages/sync-core/package.json
  • packages/storage-core/src/migrations/index.ts
  • apps/desktop/src/renderer/analytics.ts
  • .husky/pre-push
  • .husky/pre-commit
  • apps/desktop/src/renderer/ui/patterns/Modal.module.css
  • packages/wikilinks/package.json
  • packages/storage-sqlite/src/repositories/index.ts
  • apps/desktop/src/renderer/ui/patterns/Modal.tsx
  • packages/storage-core/src/repositories/index.ts
  • .husky/commit-msg
  • apps/desktop/src/renderer/hooks/useTheme.ts
  • scripts/bump-version.js

Comment on lines +30 to +65
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));

const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});

const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');

return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Resource leak if launch fails after temp directory creation.

If electron.launch() or app.firstWindow() throws, the temp directory created on line 31 is never cleaned up, since the cleanup function isn't returned. While CI runners clean up automatically, local development can accumulate orphaned temp directories if launch failures persist.

🛡️ Proposed fix to ensure cleanup on launch failure
 export async function launchApp(): Promise<LaunchedApp> {
   const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
+  
+  try {
+    const app = await electron.launch({
+      args: [
+        '.',
+        `--user-data-dir=${userDataDir}`,
+        '--disable-features=AutoUpdate',
+      ],
+      env: {
+        ...process.env,
+        NODE_ENV: 'test',
+        READIED_E2E: '1',
+        READIED_DATA_DIR: userDataDir,
+      },
+    });
 
-  const app = await electron.launch({
-    args: [
-      '.',
-      `--user-data-dir=${userDataDir}`,
-      // Disable updates / external network checks during tests.
-      '--disable-features=AutoUpdate',
-    ],
-    env: {
-      ...process.env,
-      NODE_ENV: 'test',
-      READIED_E2E: '1',
-      // Pin the data root explicitly so the app uses our temp dir for
-      // its SQLite database too, not just for Electron's userData.
-      READIED_DATA_DIR: userDataDir,
-    },
-  });
-
-  const window = await app.firstWindow();
-  // Wait for the renderer to finish initial paint.
-  await window.waitForLoadState('domcontentloaded');
-
-  return {
-    app,
-    window,
-    userDataDir,
-    cleanup: async () => {
-      await app.close().catch(() => {});
-      if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
-        await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
-      }
-    },
-  };
+    const window = await app.firstWindow();
+    await window.waitForLoadState('domcontentloaded');
+
+    return {
+      app,
+      window,
+      userDataDir,
+      cleanup: async () => {
+        await app.close().catch(() => {});
+        if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
+          await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
+        }
+      },
+    };
+  } catch (err) {
+    await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
+    throw err;
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});
const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');
return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
try {
const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});
const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');
return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
} catch (err) {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e/fixtures.ts` around lines 30 - 65, launchApp currently
creates userDataDir via mkdtemp but if electron.launch() or app.firstWindow()
throws the temp dir is orphaned; wrap the launch/firstWindow sequence in a
try/catch and on any thrown error remove the created userDataDir (using
rm(userDataDir, { recursive: true, force: true })) unless
process.env.READIED_E2E_KEEP_USERDATA === '1', then rethrow the error; reference
the launchApp function, mkdtemp call that sets userDataDir, the electron.launch
and app.firstWindow calls, and preserve the existing cleanup closure for
successful returns.

Comment on lines +47 to +51
const ignored = [
/\[Sentry\]/, // "No DSN configured" — expected without VITE_SENTRY_DSN
/Failed to load resource: net::ERR_/, // network during dev sometimes
];
const real = consoleErrors.filter(line => !ignored.some(re => re.test(line)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error filter patterns are too broad and may hide real failures.

The regex patterns /\[Sentry\]/ and /Failed to load resource: net::ERR_/ will match any console message containing those substrings, not just the specific known noise mentioned in the comment. For example, a real Sentry error like [Sentry] Fatal: API key rejected would be silently filtered, undermining the smoke test's ability to catch genuine errors.

🔍 Proposed fix with more specific patterns
       const ignored = [
-        /\[Sentry\]/, // "No DSN configured" — expected without VITE_SENTRY_DSN
-        /Failed to load resource: net::ERR_/, // network during dev sometimes
+        /\[Sentry\].*No DSN configured/, // Expected without VITE_SENTRY_DSN
+        /Failed to load resource:.*net::ERR_FILE_NOT_FOUND/, // Dev HMR artifacts
       ];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e/smoke.spec.ts` around lines 47 - 51, The current broad
filters in the ignored array (used by consoleErrors -> real) hide any message
containing "[Sentry]" or "Failed to load resource: net::ERR_", which can silence
real errors; narrow these to exact known noise by replacing those regexes with
anchored patterns or exact string matches that match the precise noise you
expect (e.g., an anchored regex for "^[\\[Sentry\\]]\\s*No DSN configured\\b" or
a full exact string for "Failed to load resource: net::ERR_NAME_NOT_RESOLVED" or
similar), update the ignored array and keep the filter using consoleErrors and
real unchanged so only the specific expected benign messages are filtered out.

Comment on lines +517 to +523
defineIpcHandler({
channel: 'subscription:openPortal',
args: z.tuple([UrlSchema]),
handler: async returnUrl => {
try {
const { url } = await client.createPortalSession(returnUrl);
void shell.openExternal(url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not let the renderer choose the billing-portal return URL.

This IPC boundary now treats the renderer as untrusted, but this handler still forwards an arbitrary URL into createPortalSession(). A compromised renderer can turn that into an attacker-controlled post-portal redirect. Keep the allowed return URLs in main (or reduce the IPC input to an enum) instead of accepting a raw URL here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/handlers/authSyncHandlers.ts` around lines 517 - 523,
The handler registered with defineIpcHandler on channel
'subscription:openPortal' currently accepts a raw UrlSchema and forwards it into
client.createPortalSession, which allows the renderer to control the post-portal
redirect; instead, restrict return URLs in main by replacing the UrlSchema
argument with a fixed enum or string key (e.g., 'dashboard' | 'billing' etc.),
maintain an internal whitelist/map of allowed return URLs in this module, look
up the corresponding safe URL before calling client.createPortalSession, and
reject/unhandled any unmapped value (throw or return an error). Update the
handler signature and use the enum/key-to-URL mapping when calling
client.createPortalSession and then shell.openExternal(url) so no unvalidated
URL from the renderer is ever forwarded.

Comment on lines +55 to 60
// Restore uses ipcMain.handle raw because the integrity-check rollback
// path is non-trivial state management — see PR #271 for the rationale.
// Validation: backupPath must be a non-empty string. We don't constrain
// it further (it comes from a native dialog), but the rollback logic
// is what guarantees safety, not the schema.
ipcMain.handle('data:backup:restore', async (_event, backupPath: string) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate backupPath in main before touching the database.

data:backup:restore still trusts a raw renderer string for a privileged file-copy operation. A compromised renderer can point this at any local file and replace the user's database with it. The main process should own the picker itself, or at least reject paths outside the backup directory / expected backup naming scheme before calling restoreBackup().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/handlers/dataHandlers.ts` around lines 55 - 60, The
handler registered via ipcMain.handle('data:backup:restore', which currently
accepts a renderer-supplied backupPath and then calls restoreBackup(), must
validate ownership before touching the DB: either move the file-picker into the
main process or reject any backupPath that is not inside the application's
configured backup directory and that does not match the expected filename
pattern; implement a check using the app's backup directory (e.g., resolve
app.getPath('userData')/backups or the existing BACKUP_DIR constant) and
canonicalize the provided path with path.resolve and path.relative to ensure it
starts with the backup directory, and if validation fails throw/reject before
calling restoreBackup(); update the handler around
ipcMain.handle('data:backup:restore', backupPath) and the call site of
restoreBackup() accordingly.

Comment on lines +179 to 206
defineIpcHandler({
channel: 'notes:list',
args: z.tuple([
z
.object({
limit: z.number().int().positive().max(100000).optional(),
offset: z.number().int().nonnegative().optional(),
tag: TagSchema.optional(),
sortBy: z.enum(['createdAt', 'updatedAt', 'title']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
archived: z.enum(['active', 'archived', 'all']).optional(),
})
.optional(),
]),
handler: async options => {
const notes = await repo.list(options);
return notes.map(note => noteToSnapshot(note));
}
);
},
});

// Search notes
ipcMain.handle('notes:search', async (_event, query: string, limit?: number) => {
const notes = await repo.search(query, limit);
return notes.map(note => noteToSnapshot(note));
defineIpcHandler({
channel: 'notes:search',
args: z.tuple([z.string().max(2048), z.number().int().positive().max(10000).optional()]),
handler: async (query, limit) => {
const notes = await repo.search(query, limit);
return notes.map(note => noteToSnapshot(note));
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and print the relevant handler sections with line numbers
FILE="apps/desktop/src/main/handlers/noteHandlers.ts"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "== Head around lines 150-240 =="
nl -ba "$FILE" | sed -n '150,240p'

echo "== Head around lines 300-380 =="
nl -ba "$FILE" | sed -n '300,380p'

# Search for embeds:saveAsset and other note IPC channels in this file
echo "== IPC channel occurrences in noteHandlers.ts =="
rg -n "defineIpcHandler\\(|channel: '([^']+)'" "$FILE"

# Find where invoke/call sites are for these channels (best-effort: search entire apps/desktop/src)
echo "== Call sites for notes:list and notes:search in apps/desktop/src =="
rg -n "invoke\\(['\"]notes:(list|search)['\"]|send\\(['\"]notes:(list|search)['\"]|ipc.*notes:(list|search)" apps/desktop/src || true

echo "== Call sites for embeds:saveAsset in apps/desktop/src =="
rg -n "invoke\\(['\"]embeds:saveAsset['\"]|send\\(['\"]embeds:saveAsset['\"]|ipc.*embeds:saveAsset" apps/desktop/src || true

# Also search for any handler arg schema for notes:* and embeds:* to confirm exact Zod shapes
echo "== Any z.tuple usage in this handler file =="
rg -n "z\\.tuple\\(" "$FILE"

Repository: tomymaritano/readide

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/desktop/src/main/handlers/noteHandlers.ts"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "== Lines 150-240 =="
cat -n "$FILE" | sed -n '150,240p'

echo "== Lines 300-380 =="
cat -n "$FILE" | sed -n '300,380p'

echo "== IPC channel occurrences in noteHandlers.ts =="
rg -n "defineIpcHandler\\(|channel: '([^']+)'" "$FILE" || true

echo "== Call sites for notes:list and notes:search in apps/desktop/src =="
rg -n "invoke\\(['\"]notes:(list|search)['\"]|send\\(['\"]notes:(list|search)['\"]|ipc.*notes:(list|search)" apps/desktop/src || true

echo "== Call sites for embeds:saveAsset in apps/desktop/src =="
rg -n "invoke\\(['\"]embeds:saveAsset['\"]|send\\(['\"]embeds:saveAsset['\"]|ipc.*embeds:saveAsset" apps/desktop/src || true

echo "== Any z.tuple usage in this handler file =="
rg -n "z\\.tuple\\(" "$FILE" || true

Repository: tomymaritano/readide

Length of output: 10490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== apps/desktop/src/preload/api/notes.ts around lines 40-90 =="
cat -n apps/desktop/src/preload/api/notes.ts | sed -n '40,100p'

echo "== apps/desktop/src/preload/api/app.ts around lines 70-130 =="
cat -n apps/desktop/src/preload/api/app.ts | sed -n '70,140p'

echo "== Sanity: any other direct call sites for these channels? =="
rg -n "ipcRenderer\\.invoke\\(['\"]notes:(list|search)['\"]" apps/desktop/src || true
rg -n "ipcRenderer\\.invoke\\(['\"]embeds:saveAsset['\"]" apps/desktop/src || true

Repository: tomymaritano/readide

Length of output: 5130


notes:list, notes:search, and embeds:saveAsset tuple schemas are compatible with current preload callers
apps/desktop/src/main/handlers/noteHandlers.ts uses fixed-length tuples, but the preload APIs always invoke:

  • notes:list with exactly one positional arg (options, possibly undefined)
  • notes:search with query plus a second positional arg (limit, possibly undefined)
  • embeds:saveAsset with four positional args (only originalName is optional)
    So the “callers will be rejected” concern doesn’t match existing usage. Consider relaxing the schemas only if you need to support IPC callers that truly omit arguments rather than passing undefined.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/handlers/noteHandlers.ts` around lines 179 - 206, The
reviewer claim that the fixed-length tuple schemas will reject current preload
callers is incorrect; do not change the schemas in defineIpcHandler for channel
'notes:list' and 'notes:search' — the tuple element types are already optional
(the options object in the 'notes:list' tuple and the second limit arg in
'notes:search'), so callers passing undefined are accepted by repo.list and
repo.search and the noteToSnapshot mapping; revert any schema relaxations or new
changes and instead add a one-line comment by the defineIpcHandler calls
(channels 'notes:list' and 'notes:search') clarifying that preload callers pass
undefined as positional args rather than omitting them, so the current
z.tuple([...optional()...]) schemas are correct.

Source: Coding guidelines

Comment thread lefthook.yml
Comment on lines +5 to +10
pre-commit:
parallel: true
commands:
lint-staged:
run: pnpm lint-staged
stage_fixed: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Remove unnecessary parallel: true from pre-commit hook.

The parallel: true setting has no effect when there is only one command. It's harmless but misleading.

♻️ Remove parallel setting
 pre-commit:
-  parallel: true
   commands:
     lint-staged:
       run: pnpm lint-staged
       stage_fixed: true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pre-commit:
parallel: true
commands:
lint-staged:
run: pnpm lint-staged
stage_fixed: true
pre-commit:
commands:
lint-staged:
run: pnpm lint-staged
stage_fixed: true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lefthook.yml` around lines 5 - 10, Remove the redundant parallel
configuration from the pre-commit hook: edit the pre-commit block where
"parallel: true" is set and delete that key so the hook contains only the
"commands" (e.g., the "lint-staged" command). Keep the "lint-staged" command and
its settings (run: pnpm lint-staged, stage_fixed: true) intact; no other changes
required.

Comment on lines +116 to +126
it('insertCodeBlock inserts a fenced code block', () => {
const t = fakeView('', { from: 0, to: 0 });
insertCodeBlock(t.view);
expect(t.doc()).toContain('```');
});

it('insertHorizontalRule inserts a markdown rule', () => {
const t = fakeView('', { from: 0, to: 0 });
insertHorizontalRule(t.view);
expect(t.doc()).toContain('---');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen block-insertion assertions to avoid false positives.

Line 119 and Line 125 only check substring presence, so malformed output can still pass. Assert markdown structure (fenced block and HR line) instead of toContain.

Suggested diff
     it('insertCodeBlock inserts a fenced code block', () => {
       const t = fakeView('', { from: 0, to: 0 });
       insertCodeBlock(t.view);
-      expect(t.doc()).toContain('```');
+      expect(t.doc()).toMatch(/```[\s\S]*```/);
     });

     it('insertHorizontalRule inserts a markdown rule', () => {
       const t = fakeView('', { from: 0, to: 0 });
       insertHorizontalRule(t.view);
-      expect(t.doc()).toContain('---');
+      expect(t.doc()).toMatch(/(^|\n)---(\n|$)/);
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/commands/src/markdown/commands.test.ts` around lines 116 - 126, The
tests for insertCodeBlock and insertHorizontalRule are too loose — replace the
toContain assertions with regex-based matches that assert proper Markdown
structure: for insertCodeBlock (in the test using fakeView and t.doc()) assert
the document matches a fenced code block pattern like /```[\s\S]*```/; for
insertHorizontalRule assert the document contains a standalone HR line using a
pattern like /(^|\n)---(\n|$)/ so partial substrings don't produce false
positives.

Comment on lines +19 to +31
/**
* Default public key for subscription-envelope verification.
*
* REPLACE BEFORE SHIPPING. This is a placeholder that does NOT correspond
* to any production server key — calls to verifySubscriptionSignature
* without an explicit publicKey will fail until this constant is updated
* with the actual server public key.
*
* The matching private key MUST live only on the licensing server. Never
* commit it.
*/
const DEFAULT_SUBSCRIPTION_PUBLIC_KEY =
'0000000000000000000000000000000000000000000000000000000000000000';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify SUBSCRIPTION_PUBLIC_KEY in desktop app is not a placeholder

# Check if the real key is set in fileLicenseStorage.ts
rg -A2 'SUBSCRIPTION_PUBLIC_KEY' apps/desktop/src/main/services/fileLicenseStorage.ts

# Verify it's not all zeros
rg '0{64}' apps/desktop/src/main/services/fileLicenseStorage.ts && echo "WARNING: Found all-zero key"

Repository: tomymaritano/readide

Length of output: 303


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find verifySubscriptionSignature call sites
rg -n "verifySubscriptionSignature\\(" -S packages apps -g'*.ts' -g'*.tsx'

# Show where publicKey argument is provided (if any)
rg -n "verifySubscriptionSignature\\([^\\)]*publicKey|verifySubscriptionSignature\\([^\\)]*public_key|publicKey\\s*:" -S packages apps -g'*.ts' -g'*.tsx' || true

# Inspect validator.ts function signature for defaulting behavior
sed -n '1,220p' packages/licensing/src/validator.ts

Repository: tomymaritano/readide

Length of output: 10194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find verifySubscriptionSignature call sites
rg -n "verifySubscriptionSignature\\(" -S packages apps -g'*.ts' -g'*.tsx'

# Inspect validator.ts function signature and defaulting behavior
sed -n '1,260p' packages/licensing/src/validator.ts

Repository: tomymaritano/readide

Length of output: 9353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "verifySubscriptionSignature\\(" -S packages apps -g'*.ts' -g'*.tsx'

sed -n '1,260p' packages/licensing/src/validator.ts

Repository: tomymaritano/readide

Length of output: 9353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show DEFAULT_SUBSCRIPTION_PUBLIC_KEY usage in validator.ts
rg -n "DEFAULT_SUBSCRIPTION_PUBLIC_KEY" packages/licensing/src/validator.ts

# Print verifySubscriptionSignature implementation
sed -n '420,560p' packages/licensing/src/validator.ts

Repository: tomymaritano/readide

Length of output: 3842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show DEFAULT_SUBSCRIPTION_PUBLIC_KEY usage in validator.ts
rg -n "DEFAULT_SUBSCRIPTION_PUBLIC_KEY" packages/licensing/src/validator.ts

# Print verifySubscriptionSignature implementation
sed -n '420,560p' packages/licensing/src/validator.ts

Repository: tomymaritano/readide

Length of output: 3842


Prevent production from using the all-zeros subscription public key fallback

apps/desktop/src/main/services/fileLicenseStorage.ts defines SUBSCRIPTION_PUBLIC_KEY with a non-zero value and passes it into verifySubscriptionSignature(..., { publicKey: SUBSCRIPTION_PUBLIC_KEY }), so production signature verification won’t rely on DEFAULT_SUBSCRIPTION_PUBLIC_KEY.

packages/licensing/src/validator.ts only falls back to DEFAULT_SUBSCRIPTION_PUBLIC_KEY when config?.publicKey is omitted; in that case verification will fail for real server envelopes (it doesn’t enable forging without the signing private key). Any future production call site should always provide config.publicKey rather than relying on the placeholder.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/licensing/src/validator.ts` around lines 19 - 31, The code currently
provides DEFAULT_SUBSCRIPTION_PUBLIC_KEY (all-zeros) as a silent fallback when
config?.publicKey is omitted; change validator.ts so production cannot
accidentally use that placeholder by removing the silent fallback and requiring
an explicit public key: update verifySubscriptionSignature to throw a clear
error if config?.publicKey is missing (or if it equals
DEFAULT_SUBSCRIPTION_PUBLIC_KEY) unless running in a safe test/dev mode (e.g.,
NODE_ENV === 'test' or an explicit allowlist), and consider making
DEFAULT_SUBSCRIPTION_PUBLIC_KEY undefined or inaccessible so callers must pass a
real key; reference DEFAULT_SUBSCRIPTION_PUBLIC_KEY and the
verifySubscriptionSignature path that currently reads config?.publicKey to
implement this guard.

Comment on lines +62 to +91
export function rowToNote(row: NoteRow, tags: Tag[]): Note {
const note = createNote({
id: createNoteId(row.id),
notebookId: createNotebookId(row.notebook_id),
title: row.title,
content: row.content,
createdAt: row.created_at as Timestamp,
isPinned: row.is_pinned === 1,
isDeleted: row.is_deleted === 1,
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
});

return {
...note,
notebookId: createNotebookId(row.notebook_id),
title: row.title,
isPinned: row.is_pinned === 1,
isDeleted: row.is_deleted === 1,
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
metadata: {
...note.metadata,
title: row.title,
createdAt: row.created_at as Timestamp,
updatedAt: row.updated_at as Timestamp,
tags,
wordCount: row.word_count,
archivedAt: row.archived_at as Timestamp | null,
},
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Simplify rowToNote to eliminate redundant field assignments.

The function calls createNote with initial values (lines 63-72), then immediately re-assigns the same values in the returned object (lines 74-90). Fields like notebookId, title, isPinned, isDeleted, and status are set twice. This is inefficient and confusing.

♻️ Proposed simplification
 export function rowToNote(row: NoteRow, tags: Tag[]): Note {
   const note = createNote({
     id: createNoteId(row.id),
     notebookId: createNotebookId(row.notebook_id),
     title: row.title,
     content: row.content,
     createdAt: row.created_at as Timestamp,
     isPinned: row.is_pinned === 1,
     isDeleted: row.is_deleted === 1,
     status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
   });

   return {
     ...note,
-    notebookId: createNotebookId(row.notebook_id),
-    title: row.title,
-    isPinned: row.is_pinned === 1,
-    isDeleted: row.is_deleted === 1,
-    status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
     metadata: {
       ...note.metadata,
-      title: row.title,
-      createdAt: row.created_at as Timestamp,
       updatedAt: row.updated_at as Timestamp,
       tags,
       wordCount: row.word_count,
       archivedAt: row.archived_at as Timestamp | null,
     },
   };
 }

Note: Only remove truly redundant fields. Keep metadata.updatedAt, metadata.tags, metadata.wordCount, and metadata.archivedAt since they override createNote defaults.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function rowToNote(row: NoteRow, tags: Tag[]): Note {
const note = createNote({
id: createNoteId(row.id),
notebookId: createNotebookId(row.notebook_id),
title: row.title,
content: row.content,
createdAt: row.created_at as Timestamp,
isPinned: row.is_pinned === 1,
isDeleted: row.is_deleted === 1,
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
});
return {
...note,
notebookId: createNotebookId(row.notebook_id),
title: row.title,
isPinned: row.is_pinned === 1,
isDeleted: row.is_deleted === 1,
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
metadata: {
...note.metadata,
title: row.title,
createdAt: row.created_at as Timestamp,
updatedAt: row.updated_at as Timestamp,
tags,
wordCount: row.word_count,
archivedAt: row.archived_at as Timestamp | null,
},
};
}
export function rowToNote(row: NoteRow, tags: Tag[]): Note {
const note = createNote({
id: createNoteId(row.id),
notebookId: createNotebookId(row.notebook_id),
title: row.title,
content: row.content,
createdAt: row.created_at as Timestamp,
isPinned: row.is_pinned === 1,
isDeleted: row.is_deleted === 1,
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
});
return {
...note,
metadata: {
...note.metadata,
updatedAt: row.updated_at as Timestamp,
tags,
wordCount: row.word_count,
archivedAt: row.archived_at as Timestamp | null,
},
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/storage-sqlite/src/repositories/noteMapping.ts` around lines 62 -
91, In rowToNote, remove the redundant reassignments of notebookId, title,
isPinned, isDeleted, and status in the returned object since createNote already
sets them; keep and set only the metadata overrides (metadata.title,
metadata.createdAt, metadata.updatedAt, metadata.tags, metadata.wordCount,
metadata.archivedAt) along with spreading ...note to preserve defaults. Update
the return to be simply { ...note, metadata: { ...note.metadata, title:
row.title, createdAt: row.created_at as Timestamp, updatedAt: row.updated_at as
Timestamp, tags, wordCount: row.word_count, archivedAt: row.archived_at as
Timestamp | null } } so rowToNote, createNote, and metadata fields remain
correct and non-redundant.

Comment thread vitest.shared.ts
'**/tests/**',
'**/dist/**',
'**/*.d.ts',
'**/index.{ts,tsx}',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider whether index files should be excluded from coverage.

Excluding **/index.{ts,tsx} is reasonable if index files only contain re-exports, but this pattern will also skip coverage for index files that contain business logic, helper functions, or configuration.

If index files in this codebase are purely re-export barrels, this exclusion is appropriate and can be ignored.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@vitest.shared.ts` at line 21, The coverage exclusion '**/index.{ts,tsx}'
currently hides all index files; inspect index files across the repo and either
(A) keep the exclusion if they are strictly re-export barrels and add a
clarifying comment near the exclusion, or (B) remove or tighten the exclusion in
the vitest.shared.ts coverage config (e.g., delete the '**/index.{ts,tsx}' entry
or replace it with a narrower glob targeting known barrel directories) so index
files that contain business logic or helpers are included in coverage.

@github-actions github-actions Bot merged commit 5829d6c into develop Jun 9, 2026
14 of 16 checks passed
github-actions Bot pushed a commit that referenced this pull request Jun 9, 2026
## Summary

Promotes `develop` to `main` for **v0.15.0**. Originally opened
2026-04-24; now refreshed with the 19-PR tech-debt audit shipped via
#285 plus the accumulated dependabot bumps reconciled.

`semantic-release` will pick the version bump. Expected: **minor
(v0.14.x → v0.15.0)** because of multiple \`feat:\` commits.

## What ships (audit highlights, from #285)

### Runtime fixes (user-facing)
- **Editor no longer crashes on table-containing notes** (#266) —
\`Decoration.replace\` over multi-line ranges moved from a ViewPlugin to
a StateField, plus an EditorView.exceptionSink so any future plugin
error no longer tears down the EditorView.
- **AI keys survive sleep/wake** (#275) — \`aiKeyStorage\` stopped
silently deleting the encrypted store when the keychain was temporarily
locked after macOS sleep.
- **Backup restore is now safe** (#271) — restored DBs go through
\`PRAGMA integrity_check\` before being swapped in; corrupt backups roll
back to the safety copy.
- **MCP server runs without electron-builder rebuilds** (#264#270) —
migrated from native \`better-sqlite3\` to built-in \`node:sqlite\`
(Node 22.5+), updated to the new \`registerTool\` MCP SDK API.

### Security
- **Typed IPC boundary** (#272 + #273 + #274) — 130+ IPC channels now
validated with Zod tuples at the main↔renderer boundary. Garbage in
fails fast with \`IpcValidationError\` instead of corrupting downstream
code.
- **Ed25519 license verification scaffolding** (#276 + #281 + #284) —
\`signSubscriptionPayload\` / \`verifySubscriptionSignature\` helpers
ship in \`@readied/licensing\`, wired into
\`FileLicenseStorage.readSubscriptionData\` with lenient fallthrough
during migration. Real public key embedded (\`d04901…\`). Server-side
\`LICENSE_SIGNING_PRIVATE_KEY\` already set in Cloudflare staging +
production.

### Developer experience
- **Husky → Lefthook** (#267) plus lint-staged that now runs ESLint, not
just Prettier.
- **\`knip\` added** (#267) + 12 unused files deleted (#279) + 6 unused
deps dropped (#280).
- **Playwright Electron E2E scaffold** (#277) with smoke + notes-IPC
specs and a Linux+xvfb CI job (\`continue-on-error: true\` while it
stabilises).
- **Vitest coverage baseline** (#269) — 12 packages share a coverage
config; smoke tests added for \`@readied/commands\`.

### Refactor (no behavior change)
- **Zustand selectors migration** (#268) — 3 components stopped
destructuring entire stores.
- **God-file extractions**:
- \`main/index.ts\` 1065 → 950 lines (#278) — \`FileLicenseStorage\`,
\`windowState\` extracted to services
- \`SQLiteNoteRepository.ts\` 1121 → 1038 lines (#282) — pure helpers
extracted to \`noteMapping.ts\`
- \`MarkdownEditor.tsx\` 737 → 612 lines (#283) — theme +
markdownHighlighting extracted to \`editorTheme.ts\`

## Deploys triggered

| Workflow | Trigger | What happens |
|---|---|---|
| \`deploy-api.yml\` | Auto on \`push\` to main affecting
\`packages/api/**\` | Tests + deploys \`@readied/api\` to Cloudflare
Workers (\`readied-api-production\`). This stack only touched
\`wrangler.toml\` + \`.dev.vars\` docs, no production code change. |
| \`release.yml\` | Manual \`workflow_dispatch\` post-merge |
\`semantic-release\` analyses conventional commits, bumps version,
creates GitHub Release draft + tag |
| \`build.yml\` | Auto on tag push from release.yml | mac / windows /
linux parallel builds, artefacts attached to the GitHub Release |

## Pre-merge verification (local, this branch)

- ✅ \`pnpm -r typecheck\` — green across 18 workspace projects
- ✅ \`pnpm test\` — 17/17 packages
- ✅ Merge resolved: take develop versions for 19 conflicted
package.jsons (develop has equal or newer deps than main's dependabot
bumps)

## Post-merge action items (operator)

1. **Deploy API to staging first** (smoke test):
   \`\`\`
   gh workflow run deploy-api.yml -f environment=staging
   \`\`\`
2. Confirm staging API responds correctly (subscription endpoint with
new \`LICENSE_SIGNING_PRIVATE_KEY\` secret already set in CF).
3. Merge this PR → auto-deploys API to production.
4. Trigger Release workflow: GitHub → Actions → Release → "Run workflow"
→ main.
5. Watch Build workflow for mac/win/linux completion.
6. Confirm the release un-drafts itself.

## Known risks / follow-ups

- **Pre-existing Vercel preview failure for \`apps/web\`** — marketing
site, scheduled to be extracted to its own repo (P3 in the roadmap).
- **\`SUBSCRIPTION_PUBLIC_KEY\` is dev-grade** — generated in a Claude
session. Before the licensing server emits envelopes for real paid
users, rotate the keypair from a trusted machine and ship a follow-up
release.
- **Branch protection should require CodeRabbit completion before
automerge** — added to the roadmap as a process item; this very PR was
BLOCKED correctly because of that policy gap being closed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant