release: bring audit stack (19 PRs) into develop#285
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis 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. ChangesIPC Handler Refactoring with Zod Validation
E2E Testing Infrastructure
Subscription Signing and Verification
Storage Layer Refactoring
Development Tooling and Code Cleanup
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested labels
✨ Finishing Touches🧪 Generate unit tests (beta)
|
…tion-public-key # Conflicts: # apps/desktop/package.json # package.json # pnpm-lock.yaml
There was a problem hiding this comment.
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 tradeoffConsider extracting
prepareFtsQueryto 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 winRemove 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (81)
.github/workflows/ci.yml.gitignore.husky/commit-msg.husky/pre-commit.husky/pre-pushapps/desktop/e2e/README.mdapps/desktop/e2e/fixtures.tsapps/desktop/e2e/notes.spec.tsapps/desktop/e2e/smoke.spec.tsapps/desktop/e2e/tsconfig.jsonapps/desktop/package.jsonapps/desktop/playwright.config.tsapps/desktop/src/main/handlers/aiKeyHandlers.tsapps/desktop/src/main/handlers/authSyncHandlers.tsapps/desktop/src/main/handlers/dataHandlers.tsapps/desktop/src/main/handlers/gitHandlers.tsapps/desktop/src/main/handlers/licenseHandlers.tsapps/desktop/src/main/handlers/localServerHandlers.tsapps/desktop/src/main/handlers/logHandlers.tsapps/desktop/src/main/handlers/noteHandlers.tsapps/desktop/src/main/handlers/notebookHandlers.tsapps/desktop/src/main/handlers/pluginHandlers.tsapps/desktop/src/main/handlers/shareHandlers.tsapps/desktop/src/main/handlers/updateHandlers.tsapps/desktop/src/main/index.tsapps/desktop/src/main/ipc/registry.tsapps/desktop/src/main/services/aiKeyStorage.tsapps/desktop/src/main/services/fileLicenseStorage.tsapps/desktop/src/main/services/windowState.tsapps/desktop/src/renderer/analytics.tsapps/desktop/src/renderer/components/MarkdownEditor.tsxapps/desktop/src/renderer/components/auth/MagicLinkFlow.tsxapps/desktop/src/renderer/components/editorTheme.tsapps/desktop/src/renderer/components/sync/EnableSyncModal.tsxapps/desktop/src/renderer/hooks/useTheme.tsapps/desktop/src/renderer/pages/settings/sections/AccountSection.tsxapps/desktop/src/renderer/plugins/tables.tsxapps/desktop/src/renderer/settings.tsxapps/desktop/src/renderer/tsconfig.jsonapps/desktop/src/renderer/ui/patterns/.gitkeepapps/desktop/src/renderer/ui/patterns/Modal.module.cssapps/desktop/src/renderer/ui/patterns/Modal.tsxapps/desktop/src/renderer/ui/patterns/index.tsapps/desktop/vitest.config.tsknip.jsonlefthook.ymlpackage.jsonpackages/api/.dev.varspackages/api/vitest.config.tspackages/api/wrangler.tomlpackages/commands/src/markdown/commands.test.tspackages/commands/vitest.config.tspackages/core/vitest.config.tspackages/embeds/vitest.config.tspackages/licensing/README.mdpackages/licensing/__tests__/subscriptionSignature.test.tspackages/licensing/src/index.tspackages/licensing/src/types.tspackages/licensing/src/validator.tspackages/licensing/tsconfig.jsonpackages/licensing/vitest.config.tspackages/mcp-server/src/index.tspackages/plugin-api/src/editor/types.tspackages/plugin-api/vitest.config.tspackages/product-config/vitest.config.tspackages/storage-core/src/interfaces/index.tspackages/storage-core/src/migrations/index.tspackages/storage-core/src/repositories/index.tspackages/storage-core/src/types/index.tspackages/storage-core/vitest.config.tspackages/storage-sqlite/src/repositories/SQLiteNoteRepository.tspackages/storage-sqlite/src/repositories/index.tspackages/storage-sqlite/src/repositories/noteMapping.tspackages/storage-sqlite/vitest.config.tspackages/sync-core/package.jsonpackages/tasks/vitest.config.tspackages/wikilinks/package.jsonpackages/wikilinks/vitest.config.tsscripts/bump-version.jstsconfig.base.jsonvitest.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
| 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(() => {}); | ||
| } | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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))); |
There was a problem hiding this comment.
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.
| defineIpcHandler({ | ||
| channel: 'subscription:openPortal', | ||
| args: z.tuple([UrlSchema]), | ||
| handler: async returnUrl => { | ||
| try { | ||
| const { url } = await client.createPortalSession(returnUrl); | ||
| void shell.openExternal(url); |
There was a problem hiding this comment.
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.
| // 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) => { |
There was a problem hiding this comment.
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.
| 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)); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧹 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" || trueRepository: 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 || trueRepository: 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:listwith exactly one positional arg (options, possiblyundefined)notes:searchwithqueryplus a second positional arg (limit, possiblyundefined)embeds:saveAssetwith four positional args (onlyoriginalNameis 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 passingundefined.
🤖 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
| pre-commit: | ||
| parallel: true | ||
| commands: | ||
| lint-staged: | ||
| run: pnpm lint-staged | ||
| stage_fixed: true |
There was a problem hiding this comment.
🧹 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.
| 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.
| 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('---'); | ||
| }); |
There was a problem hiding this comment.
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.
| /** | ||
| * 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'; |
There was a problem hiding this comment.
🧹 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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.
| 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, | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🧹 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.
| 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.
| '**/tests/**', | ||
| '**/dist/**', | ||
| '**/*.d.ts', | ||
| '**/index.{ts,tsx}', |
There was a problem hiding this comment.
🧹 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.
## 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>
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)
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)
After this PR merges
Per the release flow in `CLAUDE.md`:
Notable behaviour changes for users
Notable for reviewers
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests
Chores