diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4d7244c..c469226e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,50 @@ jobs: working-directory: apps/desktop run: pnpm typecheck + # ── Tier 2: E2E (Playwright + Electron, xvfb on Linux) ───────────────── + # Starts as continue-on-error: true while we stabilize the suite. + # Flip to required once it's reliably green on develop. + e2e: + needs: setup + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Restore node_modules + uses: actions/cache/restore@v4 + with: + path: | + node_modules + apps/*/node_modules + packages/*/node_modules + key: modules-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright system deps + working-directory: apps/desktop + run: npx playwright install-deps chromium + + - name: Build desktop bundle + run: pnpm --filter @readied/desktop build + + - name: Run Playwright E2E (xvfb) + working-directory: apps/desktop + run: xvfb-run --auto-servernum pnpm e2e + env: + CI: 'true' + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/desktop/playwright-report/ + retention-days: 7 + # ── Tier 3: Security audit ───────────────────────── security: needs: setup diff --git a/.gitignore b/.gitignore index 96f7a24c..4b171feb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,14 @@ npm-debug.log* # Cache .eslintcache +# Vitest coverage reports +coverage/ + +# Playwright artifacts +test-results/ +playwright-report/ +playwright/.cache/ + # Screenshots (root level only) /CleanShot*.png diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100644 index 9ef41ae4..00000000 --- a/.husky/commit-msg +++ /dev/null @@ -1 +0,0 @@ -pnpm commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index cb2c84d5..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm lint-staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100644 index 0db91f05..00000000 --- a/.husky/pre-push +++ /dev/null @@ -1 +0,0 @@ -pnpm typecheck diff --git a/apps/desktop/e2e/README.md b/apps/desktop/e2e/README.md new file mode 100644 index 00000000..64e83a60 --- /dev/null +++ b/apps/desktop/e2e/README.md @@ -0,0 +1,49 @@ +# E2E tests (Playwright + Electron) + +End-to-end tests for the desktop app, driven through Playwright's `_electron` API. Tests launch the **built** Electron bundle in `out/`, so you must run `pnpm build` (or `pnpm dev` for headed iteration) before they pass. + +## Running locally + +```bash +# From repo root +pnpm --filter @readied/desktop build # produces out/main/index.js +pnpm --filter @readied/desktop e2e # headless +pnpm --filter @readied/desktop e2e:headed # opens the window +``` + +First run also downloads Playwright's browser binaries: + +```bash +npx playwright install --with-deps +``` + +(`--with-deps` only matters on Linux, where it installs system libs.) + +## Isolation + +`launchApp()` in `fixtures.ts` creates a fresh temp `userData` dir per test, so: + +- The SQLite DB starts empty every time. +- Settings, license cache, AI keys, etc. don't leak between tests. +- The host's real Readied data is never touched. + +Set `READIED_E2E_KEEP_USERDATA=1` to keep the temp dir on failure for post-mortem inspection. + +## What we test + +| Spec | What it covers | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `smoke.spec.ts` | App launches, main window renders, IPC bridge present, no uncaught console errors during initial mount. This is the regression catch for #266 (editor mount crashes producing blank windows). | +| `notes.spec.ts` | Notes IPC contract — create / list / get roundtrip, FTS5 search returns freshly-created notes. We deliberately drive the **preload bridge** (`window.readied.notes.*`) rather than the editor UI; selectors churn but the contract is stable. | + +## What we deliberately don't test (yet) + +- **Editor UI interactions** (typing, formatting, hotkeys). The CodeMirror surface is too prone to flake without per-spec selectors. Worth doing once the editor is split (see PR-G in the audit). +- **AI panel streaming.** Needs a mock provider and is more useful as a vitest test against `@readied/ai-core`. +- **Sync flows.** Need a fake server. + +These will be follow-ups once the basics are stable in CI. + +## CI + +The `e2e` job in `.github/workflows/ci.yml` runs on Linux + xvfb. It starts as `continue-on-error: true` — the goal of this PR is to land the infrastructure, not to gate every PR on E2E green. Once the suite is verified end-to-end on a real CI run, flip the flag off in a follow-up. diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 00000000..21e596bd --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,65 @@ +/** + * Shared E2E fixtures for Electron app tests. + * + * `launchApp()` launches a fresh Electron instance with an isolated + * userData directory so tests don't interfere with each other or with + * a developer's local Readied install. Each test should call this in + * its own `beforeEach`. + */ + +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test'; + +interface LaunchedApp { + app: ElectronApplication; + window: Page; + userDataDir: string; + /** Call in afterEach. */ + cleanup: () => Promise; +} + +/** + * Launches the desktop app and waits for the first window to be ready. + * + * Uses a fresh temp `userData` so the test gets an empty database every + * time. Set READIED_E2E_KEEP_USERDATA=1 to keep the dir on failure for + * post-mortem. + */ +export async function launchApp(): Promise { + 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(() => {}); + } + }, + }; +} diff --git a/apps/desktop/e2e/notes.spec.ts b/apps/desktop/e2e/notes.spec.ts new file mode 100644 index 00000000..7552ea18 --- /dev/null +++ b/apps/desktop/e2e/notes.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; +import { launchApp } from './fixtures.js'; + +/** + * Notes CRUD end-to-end. + * + * We exercise the IPC contract directly through the preload bridge + * (`window.readied.notes`) rather than driving the editor UI. This is + * intentional: + * - The UI elements (selectors, labels, hotkeys) churn often. Asserting + * against the IPC surface gives us regression coverage on the + * *contract* that survives renderer refactors. + * - Anything that breaks here also breaks the desktop's renderer code, + * because the renderer uses the same bridge. + */ +test.describe('notes IPC contract', () => { + test('create → list → read roundtrip', async () => { + const { window, cleanup } = await launchApp(); + try { + const noteId = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const content = '# E2E note\n\nbody from playwright'; + + const createResult = await window.evaluate( + async ([id, body]) => { + const api = ( + window as unknown as { + readied: { + notes: { + create: (input: { + id?: string; + content: string; + notebookId?: string; + }) => Promise; + list: ( + opts?: Record + ) => Promise>; + get: (id: string) => Promise; + }; + }; + } + ).readied; + const created = await api.notes.create({ id, content: body }); + return { created }; + }, + [noteId, content] as const + ); + + expect(createResult.created).toBeTruthy(); + + const list = await window.evaluate( + async () => + ( + window as unknown as { + readied: { + notes: { + list: () => Promise>; + }; + }; + } + ).readied.notes.list(), + undefined + ); + + const ourNote = list.find(n => n.id === noteId); + expect(ourNote, `note ${noteId} missing from list`).toBeDefined(); + expect(ourNote!.content).toContain('body from playwright'); + } finally { + await cleanup(); + } + }); + + test('search returns the freshly-created note via FTS5', async () => { + const { window, cleanup } = await launchApp(); + try { + const marker = `marker_${Date.now()}_unique`; + await window.evaluate( + async ([body]) => { + const api = ( + window as unknown as { + readied: { + notes: { create: (input: { content: string }) => Promise }; + }; + } + ).readied; + await api.notes.create({ content: `# Searchable\n\n${body}` }); + }, + [marker] as const + ); + + const results = await window.evaluate( + async ([q]) => + ( + window as unknown as { + readied: { + notes: { + search: ( + query: string, + limit?: number + ) => Promise>; + }; + }; + } + ).readied.notes.search(q, 10), + [marker] as const + ); + + expect(results.length).toBeGreaterThan(0); + expect(results.some(r => r.content.includes(marker))).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts new file mode 100644 index 00000000..85bf76b2 --- /dev/null +++ b/apps/desktop/e2e/smoke.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from '@playwright/test'; +import { launchApp } from './fixtures.js'; + +test.describe('app launch (smoke)', () => { + test('launches and shows the main window', async () => { + const { app, window, cleanup } = await launchApp(); + try { + // Title is "Readied" in production. Allow any non-empty title in case + // dev/test envs use a different one. + const title = await window.title(); + expect(title.length).toBeGreaterThan(0); + + // First window must render *something* — a element with non-zero + // size is a low bar that catches the regression class from PR #266 + // (editor mount crashes that produced a blank window). + const bodyBox = await window.locator('body').boundingBox(); + expect(bodyBox).not.toBeNull(); + expect(bodyBox!.width).toBeGreaterThan(0); + expect(bodyBox!.height).toBeGreaterThan(0); + + // Sanity: the app exposed its IPC bridge. + const hasBridge = await window.evaluate( + () => typeof (window as unknown as { readied?: unknown }).readied !== 'undefined' + ); + expect(hasBridge).toBe(true); + + expect(app.windows().length).toBeGreaterThanOrEqual(1); + } finally { + await cleanup(); + } + }); + + test('console does not log uncaught errors during initial render', async () => { + const { window, cleanup } = await launchApp(); + const consoleErrors: string[] = []; + window.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + window.on('pageerror', err => consoleErrors.push(`pageerror: ${err.message}`)); + + try { + // Give the renderer 3s to throw any early errors during mount. + await window.waitForTimeout(3000); + + // Known non-fatal noise that the app emits in test/dev environments. + // Strip these out before asserting "no errors". + 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))); + + expect(real, real.join('\n')).toEqual([]); + } finally { + await cleanup(); + } + }); +}); diff --git a/apps/desktop/e2e/tsconfig.json b/apps/desktop/e2e/tsconfig.json new file mode 100644 index 00000000..1eb7b599 --- /dev/null +++ b/apps/desktop/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "rootDir": ".", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c7ee6ebf..3ca3601f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,14 +18,17 @@ "typecheck:main": "tsc --noEmit -p src/main/tsconfig.json", "typecheck:preload": "tsc --noEmit -p src/preload/tsconfig.json", "typecheck:renderer": "tsc --noEmit -p src/renderer/tsconfig.json", - "typecheck": "pnpm run typecheck:main && pnpm run typecheck:preload && pnpm run typecheck:renderer", + "typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json", + "typecheck": "pnpm run typecheck:main && pnpm run typecheck:preload && pnpm run typecheck:renderer && pnpm run typecheck:e2e", "pack": "electron-builder --dir", "dist": "electron-builder", "dist:mac": "electron-builder --mac", "dist:win": "electron-builder --win", "dist:linux": "electron-builder --linux", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", @@ -42,18 +45,15 @@ "cross-fetch": "^4.1.0", "diff": "^9.0.0", "electron-updater": "^6.8.9", - "highlight.js": "^11.11.1", "isomorphic-git": "^1.38.4", "lucide-react": "^1.17.0", "pino": "^10.3.1", - "pino-roll": "^4.0.0", "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.11.2", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", - "unist-util-visit": "^5.1.0", + "zod": "^4.4.3", "zustand": "^5.0.14" }, "devDependencies": { @@ -70,16 +70,15 @@ "@readied/tasks": "workspace:*", "@readied/wikilinks": "workspace:*", "@types/better-sqlite3": "^7.6.12", - "@types/mdast": "^4.0.4", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/turndown": "^5.0.6", + "@playwright/test": "^1.49.1", "@vitejs/plugin-react": "^6.0.2", "electron": "^42.3.3", "electron-builder": "^26.15.2", "electron-devtools-installer": "^4.0.0", "electron-vite": "^5.0.0", - "pino-pretty": "^13.1.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-force-graph-2d": "^1.29.1", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 00000000..2a52a84e --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright config for the Readied Electron app. + * + * Tests are end-to-end against the built Electron bundle in `out/`, + * launched via Playwright's `electron` API (`_electron.launch`). + * + * Before running: `pnpm build` to produce `out/main/index.js`. + * + * Local: `pnpm e2e` — headless against the build + * `pnpm e2e:headed` — open the actual window + * + * On CI we run linux + xvfb. See .github/workflows/ci.yml. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, // Electron app state is shared; tests run serially + workers: 1, + retries: process.env.CI ? 2 : 0, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, +}); diff --git a/apps/desktop/src/main/handlers/aiKeyHandlers.ts b/apps/desktop/src/main/handlers/aiKeyHandlers.ts index 74286126..ad084928 100644 --- a/apps/desktop/src/main/handlers/aiKeyHandlers.ts +++ b/apps/desktop/src/main/handlers/aiKeyHandlers.ts @@ -2,35 +2,62 @@ * AI Key Storage IPC Handlers * * Handles saving, retrieving, and managing AI provider API keys. + * + * Inputs are validated at the IPC boundary via Zod. Renderer-supplied + * provider names and keys are bounded in length and shape — a malformed + * payload throws an IpcValidationError before ever reaching aiKeyStorage. */ -import { ipcMain } from 'electron'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { AiKeyStorage } from './types.js'; export interface AiKeyHandlerDeps { aiKeyStorage: AiKeyStorage; } +// A provider name is short, kebab-case-ish, and ASCII. Cap at 64 to defend +// against accidental large inputs. +const ProviderSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-zA-Z0-9_-]+$/, 'Provider name must be alphanumeric (with _ or -)'); + +// Keys can be long (sk-... up to a few hundred chars on some providers); +// 4096 is well above anything real and well below "this is junk". +const ApiKeySchema = z.string().min(1).max(4096); + export function registerAiKeyHandlers(deps: AiKeyHandlerDeps): void { const { aiKeyStorage } = deps; - ipcMain.handle('ai:saveKey', async (_event, provider: string, apiKey: string) => { - await aiKeyStorage.saveKey(provider, apiKey); + defineIpcHandler({ + channel: 'ai:saveKey', + args: z.tuple([ProviderSchema, ApiKeySchema]), + handler: (provider, apiKey) => aiKeyStorage.saveKey(provider, apiKey), }); - ipcMain.handle('ai:getKey', async (_event, provider: string) => { - return aiKeyStorage.getKey(provider); + defineIpcHandler({ + channel: 'ai:getKey', + args: z.tuple([ProviderSchema]), + handler: provider => aiKeyStorage.getKey(provider), }); - ipcMain.handle('ai:removeKey', async (_event, provider: string) => { - await aiKeyStorage.removeKey(provider); + defineIpcHandler({ + channel: 'ai:removeKey', + args: z.tuple([ProviderSchema]), + handler: provider => aiKeyStorage.removeKey(provider), }); - ipcMain.handle('ai:hasKey', async (_event, provider: string) => { - return aiKeyStorage.hasKey(provider); + defineIpcHandler({ + channel: 'ai:hasKey', + args: z.tuple([ProviderSchema]), + handler: provider => aiKeyStorage.hasKey(provider), }); - ipcMain.handle('ai:listConnectedProviders', async () => { - return aiKeyStorage.listProviders(); + defineIpcHandler({ + channel: 'ai:listConnectedProviders', + args: z.tuple([]), + handler: () => aiKeyStorage.listProviders(), }); } diff --git a/apps/desktop/src/main/handlers/authSyncHandlers.ts b/apps/desktop/src/main/handlers/authSyncHandlers.ts index 7cb3517d..472616d4 100644 --- a/apps/desktop/src/main/handlers/authSyncHandlers.ts +++ b/apps/desktop/src/main/handlers/authSyncHandlers.ts @@ -5,7 +5,9 @@ * subscription/billing, and device management. */ -import { ipcMain, shell } from 'electron'; +import { shell } from 'electron'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { ApiClient, EncryptionService, @@ -22,6 +24,29 @@ export interface AuthSyncHandlerDeps { broadcastToWindows: BroadcastFn; } +const EmailSchema = z.string().email().max(254); +const TokenSchema = z.string().min(1).max(2048); +const PassphraseSchema = z.string().min(1).max(1024); +const RecoveryKeySchema = z.string().min(8).max(512); +const IdSchema = z.string().min(1).max(128); +const NameSchema = z.string().min(1).max(128); +const KeyHexSchema = z + .string() + .min(32) + .max(256) + .regex(/^[a-f0-9]+$/i); +const UrlSchema = z.string().url().max(2048); + +const SyncChangeSchema = z.object({ + noteId: IdSchema, + operation: z.enum(['create', 'update', 'delete']), + content: z + .string() + .max(10 * 1024 * 1024) + .optional(), + localVersion: z.number().int().nonnegative().optional(), +}); + export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void { const { apiClient: client, @@ -30,7 +55,6 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void { encryptionService: encryption, } = deps; - // Broadcast sync status events to all renderer windows sync.onStatusChange(event => { deps.broadcastToWindows('sync:status-changed', event); }); @@ -39,556 +63,603 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void { // Authentication // ═══════════════════════════════════════════════════════════════════════════ - // Request magic link email - ipcMain.handle('auth:requestMagicLink', async (_event, email: string) => { - try { - await client.requestMagicLink(email); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to request magic link', - }; - } - }); - - // Verify magic link token and save tokens - ipcMain.handle('auth:verify', async (_event, token: string) => { - try { - const result = await client.verifyMagicLink(token); - await storage.saveTokens(result.accessToken, result.refreshToken); - return { success: true, user: result.user }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to verify token', - }; - } - }); - - // Get current session - ipcMain.handle('auth:getSession', async () => { - try { - const hasTokens = await storage.hasTokens(); - if (!hasTokens) { + defineIpcHandler({ + channel: 'auth:requestMagicLink', + args: z.tuple([EmailSchema]), + handler: async email => { + try { + await client.requestMagicLink(email); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to request magic link', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'auth:verify', + args: z.tuple([TokenSchema]), + handler: async token => { + try { + const result = await client.verifyMagicLink(token); + await storage.saveTokens(result.accessToken, result.refreshToken); + return { success: true, user: result.user }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to verify token', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'auth:getSession', + args: z.tuple([]), + handler: async () => { + try { + const hasTokens = await storage.hasTokens(); + if (!hasTokens) return null; + const user = await client.getCurrentUser(); + return { user }; + } catch { + await storage.clearTokens(); return null; } + }, + }); - const user = await client.getCurrentUser(); - return { user }; - } catch (_error) { - // If session is invalid, clear tokens - await storage.clearTokens(); - return null; - } - }); - - // Logout and clear tokens - ipcMain.handle('auth:logout', async () => { - try { - // Abort any in-flight sync operations before clearing tokens - sync?.stopAutoSync(); - await storage.clearTokens(); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to logout', - }; - } - }); - - // Refresh access token - ipcMain.handle('auth:refreshToken', async () => { - try { - const refreshed = await client.refreshAccessToken(); - return { success: refreshed }; - } catch (_error) { - return { success: false }; - } + defineIpcHandler({ + channel: 'auth:logout', + args: z.tuple([]), + handler: async () => { + try { + sync?.stopAutoSync(); + await storage.clearTokens(); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to logout', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'auth:refreshToken', + args: z.tuple([]), + handler: async () => { + try { + const refreshed = await client.refreshAccessToken(); + return { success: refreshed }; + } catch { + return { success: false }; + } + }, }); // ═══════════════════════════════════════════════════════════════════════════ // Sync // ═══════════════════════════════════════════════════════════════════════════ - // Pull changes from server - ipcMain.handle('sync:pull', async () => { - try { - const result = await sync.pull(); - return { - success: result.success, - changes: result.changes, - cursor: result.cursor, - hasMore: result.hasMore, - conflicts: result.conflicts, - error: result.error, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to pull changes', - }; - } - }); - - // Push changes to server - ipcMain.handle( - 'sync:push', - async ( - _event, - changes: Array<{ - noteId: string; - operation: 'create' | 'update' | 'delete'; - content?: string; - localVersion?: number; - }> - ) => { + defineIpcHandler({ + channel: 'sync:pull', + args: z.tuple([]), + handler: async () => { try { - const result = await sync.push(changes); + const result = await sync.pull(); return { success: result.success, - results: result.results, + changes: result.changes, + cursor: result.cursor, + hasMore: result.hasMore, + conflicts: result.conflicts, error: result.error, }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to pull changes', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:push', + args: z.tuple([z.array(SyncChangeSchema).max(100000)]), + handler: async changes => { + try { + const result = await sync.push(changes); + return { success: result.success, results: result.results, error: result.error }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to push changes', }; } - } - ); - - // Perform full sync (pull + push) - ipcMain.handle('sync:syncNow', async () => { - try { - const result = await sync.syncNow(); - return result; - } catch (error) { - return { - success: false, - changesApplied: 0, - changesPushed: 0, - conflicts: [], - error: error instanceof Error ? error.message : 'Sync failed', - }; - } - }); - - // Get sync status - ipcMain.handle('sync:status', async () => { - try { - const state = sync.getState(); - return { - success: true, - cursor: state.cursor, - lastSyncAt: state.lastSyncAt, - isSyncing: state.isSyncing, - lastError: state.lastError, - consecutiveFailures: state.consecutiveFailures, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get sync status', - }; - } - }); - - // Get pending change count (offline queue size) - ipcMain.handle('sync:pendingCount', async () => { - try { - return { success: true, count: sync.getPendingCount() }; - } catch (error) { - return { success: false, count: 0, error: error instanceof Error ? error.message : 'Failed' }; - } - }); - - // Resolve conflict - ipcMain.handle( - 'sync:resolveConflict', - async (_event, noteId: string, resolution: 'local' | 'remote') => { + }, + }); + + defineIpcHandler({ + channel: 'sync:syncNow', + args: z.tuple([]), + handler: async () => { try { - await sync.resolveConflict(noteId, resolution); + return await sync.syncNow(); + } catch (error) { + return { + success: false, + changesApplied: 0, + changesPushed: 0, + conflicts: [], + error: error instanceof Error ? error.message : 'Sync failed', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:status', + args: z.tuple([]), + handler: () => { + try { + const state = sync.getState(); return { success: true, + cursor: state.cursor, + lastSyncAt: state.lastSyncAt, + isSyncing: state.isSyncing, + lastError: state.lastError, + consecutiveFailures: state.consecutiveFailures, }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get sync status', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:pendingCount', + args: z.tuple([]), + handler: () => { + try { + return { success: true, count: sync.getPendingCount() }; + } catch (error) { + return { + success: false, + count: 0, + error: error instanceof Error ? error.message : 'Failed', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:resolveConflict', + args: z.tuple([IdSchema, z.enum(['local', 'remote'])]), + handler: async (noteId, resolution) => { + try { + await sync.resolveConflict(noteId, resolution); + return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to resolve conflict', }; } - } - ); - - // Start auto-sync - ipcMain.handle('sync:startAutoSync', async (_event, intervalMs?: number) => { - try { - sync.startAutoSync(intervalMs); - return { - success: true, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to start auto-sync', - }; - } - }); - - // Stop auto-sync - ipcMain.handle('sync:stopAutoSync', async () => { - try { - sync.stopAutoSync(); - return { - success: true, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to stop auto-sync', - }; - } - }); - - // Tag sync - pull - ipcMain.handle('sync:pullTags', async () => { - try { - return await sync.pullTags(); - } catch (error) { - return { success: false, applied: 0, error: String(error) }; - } - }); - - // Tag sync - push - ipcMain.handle('sync:pushTags', async () => { - try { - return await sync.pushTags(); - } catch (error) { - return { success: false, pushed: 0, error: String(error) }; - } - }); - - ipcMain.handle('sync:history', async (_event, limit?: number) => { - try { - const history = sync.getSyncHistory(limit); - return { success: true, history }; - } catch (error) { - return { - success: false, - history: [], - error: error instanceof Error ? error.message : 'Failed to get sync history', - }; - } + }, + }); + + defineIpcHandler({ + channel: 'sync:startAutoSync', + args: z.tuple([ + z + .number() + .int() + .min(1000) + .max(24 * 60 * 60 * 1000) + .optional(), + ]), + handler: intervalMs => { + try { + sync.startAutoSync(intervalMs); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to start auto-sync', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:stopAutoSync', + args: z.tuple([]), + handler: () => { + try { + sync.stopAutoSync(); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to stop auto-sync', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:pullTags', + args: z.tuple([]), + handler: async () => { + try { + return await sync.pullTags(); + } catch (error) { + return { success: false, applied: 0, error: String(error) }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:pushTags', + args: z.tuple([]), + handler: async () => { + try { + return await sync.pushTags(); + } catch (error) { + return { success: false, pushed: 0, error: String(error) }; + } + }, + }); + + defineIpcHandler({ + channel: 'sync:history', + args: z.tuple([z.number().int().positive().max(10000).optional()]), + handler: limit => { + try { + const history = sync.getSyncHistory(limit); + return { success: true, history }; + } catch (error) { + return { + success: false, + history: [], + error: error instanceof Error ? error.message : 'Failed to get sync history', + }; + } + }, }); // ═══════════════════════════════════════════════════════════════════════════ // E2EE Key Management // ═══════════════════════════════════════════════════════════════════════════ - // Check if encryption is ready (CEK cached locally) - ipcMain.handle('encryption:isReady', async () => { - return { ready: encryption?.isReady() ?? false }; - }); - - // Check if this is a first-time setup or existing user - ipcMain.handle('encryption:getKeyStatus', async () => { - try { - const serverKeys = await client.getEncryptionKeys(); - const hasLocalKey = encryption?.isReady() ?? false; - const hasLegacyKey = encryption?.hasLegacyKey() ?? false; - - return { - success: true, - hasServerKeys: serverKeys.exists, - hasLocalKey, - hasLegacyKey, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get key status', - }; - } - }); - - // First device: set up encryption keys with passphrase - ipcMain.handle('encryption:setupKeys', async (_event, passphrase: string) => { - try { - if (!encryption) throw new Error('Encryption service not available'); - - const result = await encryption.setupKeys(passphrase); - - // Upload to server - await client.setEncryptionKeys({ - salt: result.salt, - wrappedCek: result.wrappedCek, - wrappedCekRecovery: result.wrappedCekRecovery, - kdfParams: result.kdfParams, - }); - - return { - success: true, - recoveryKey: result.recoveryKey, // Show once to user! - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to setup encryption keys', - }; - } - }); - - // New device: unlock with passphrase - ipcMain.handle('encryption:unlockWithPassphrase', async (_event, passphrase: string) => { - try { - if (!encryption) throw new Error('Encryption service not available'); - - const serverKeys = await client.getEncryptionKeys(); - if ( - !serverKeys.exists || - !serverKeys.salt || - !serverKeys.wrappedCek || - !serverKeys.kdfParams - ) { - return { success: false, error: 'No encryption keys found on server' }; - } - - await encryption.unlockWithPassphrase( - passphrase, - serverKeys.salt, - serverKeys.wrappedCek, - serverKeys.kdfParams - ); - - return { success: true }; - } catch (error) { - const msg = error instanceof Error ? error.message : 'Failed to unlock'; - const isWrongPassphrase = msg.includes('incorrect passphrase') || msg.includes('unwrap'); - return { - success: false, - wrongPassphrase: isWrongPassphrase, - error: isWrongPassphrase ? 'Incorrect passphrase' : msg, - }; - } - }); - - // Unlock with recovery key - ipcMain.handle('encryption:unlockWithRecoveryKey', async (_event, recoveryKey: string) => { - try { - if (!encryption) throw new Error('Encryption service not available'); - - const serverKeys = await client.getEncryptionKeys(); - if (!serverKeys.exists || !serverKeys.wrappedCekRecovery) { - return { success: false, error: 'No recovery key found on server' }; - } - - await encryption.unlockWithRecoveryKey(recoveryKey, serverKeys.wrappedCekRecovery); - - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to unlock with recovery key', - }; - } - }); - - // Migrate legacy per-device key to key hierarchy - ipcMain.handle('encryption:migrateLegacyKey', async (_event, passphrase: string) => { - try { - if (!encryption) throw new Error('Encryption service not available'); - - const result = await encryption.migrateLegacyKey(passphrase); - - // Upload to server - await client.setEncryptionKeys({ - salt: result.salt, - wrappedCek: result.wrappedCek, - wrappedCekRecovery: result.wrappedCekRecovery, - kdfParams: result.kdfParams, - }); - - return { - success: true, - recoveryKey: result.recoveryKey, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to migrate legacy key', - }; - } - }); - - // Change passphrase (re-wrap CEK) - ipcMain.handle('encryption:changePassphrase', async (_event, newPassphrase: string) => { - try { - if (!encryption) throw new Error('Encryption service not available'); - - const result = await encryption.changePassphrase(newPassphrase); - - // Upload new wrapped key to server - await client.setEncryptionKeys({ - salt: result.salt, - wrappedCek: result.wrappedCek, - kdfParams: result.kdfParams, - }); - - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to change passphrase', - }; - } + defineIpcHandler({ + channel: 'encryption:isReady', + args: z.tuple([]), + handler: () => ({ ready: encryption?.isReady() ?? false }), + }); + + defineIpcHandler({ + channel: 'encryption:getKeyStatus', + args: z.tuple([]), + handler: async () => { + try { + const serverKeys = await client.getEncryptionKeys(); + const hasLocalKey = encryption?.isReady() ?? false; + const hasLegacyKey = encryption?.hasLegacyKey() ?? false; + return { + success: true, + hasServerKeys: serverKeys.exists, + hasLocalKey, + hasLegacyKey, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get key status', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:setupKeys', + args: z.tuple([PassphraseSchema]), + handler: async passphrase => { + try { + if (!encryption) throw new Error('Encryption service not available'); + const result = await encryption.setupKeys(passphrase); + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + wrappedCekRecovery: result.wrappedCekRecovery, + kdfParams: result.kdfParams, + }); + return { success: true, recoveryKey: result.recoveryKey }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to setup encryption keys', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:unlockWithPassphrase', + args: z.tuple([PassphraseSchema]), + handler: async passphrase => { + try { + if (!encryption) throw new Error('Encryption service not available'); + const serverKeys = await client.getEncryptionKeys(); + if ( + !serverKeys.exists || + !serverKeys.salt || + !serverKeys.wrappedCek || + !serverKeys.kdfParams + ) { + return { success: false, error: 'No encryption keys found on server' }; + } + await encryption.unlockWithPassphrase( + passphrase, + serverKeys.salt, + serverKeys.wrappedCek, + serverKeys.kdfParams + ); + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to unlock'; + const isWrongPassphrase = msg.includes('incorrect passphrase') || msg.includes('unwrap'); + return { + success: false, + wrongPassphrase: isWrongPassphrase, + error: isWrongPassphrase ? 'Incorrect passphrase' : msg, + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:unlockWithRecoveryKey', + args: z.tuple([RecoveryKeySchema]), + handler: async recoveryKey => { + try { + if (!encryption) throw new Error('Encryption service not available'); + const serverKeys = await client.getEncryptionKeys(); + if (!serverKeys.exists || !serverKeys.wrappedCekRecovery) { + return { success: false, error: 'No recovery key found on server' }; + } + await encryption.unlockWithRecoveryKey(recoveryKey, serverKeys.wrappedCekRecovery); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to unlock with recovery key', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:migrateLegacyKey', + args: z.tuple([PassphraseSchema]), + handler: async passphrase => { + try { + if (!encryption) throw new Error('Encryption service not available'); + const result = await encryption.migrateLegacyKey(passphrase); + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + wrappedCekRecovery: result.wrappedCekRecovery, + kdfParams: result.kdfParams, + }); + return { success: true, recoveryKey: result.recoveryKey }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to migrate legacy key', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:changePassphrase', + args: z.tuple([PassphraseSchema]), + handler: async newPassphrase => { + try { + if (!encryption) throw new Error('Encryption service not available'); + const result = await encryption.changePassphrase(newPassphrase); + await client.setEncryptionKeys({ + salt: result.salt, + wrappedCek: result.wrappedCek, + kdfParams: result.kdfParams, + }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to change passphrase', + }; + } + }, }); // ═══════════════════════════════════════════════════════════════════════════ // Subscription // ═══════════════════════════════════════════════════════════════════════════ - // Get subscription status - ipcMain.handle('subscription:getStatus', async () => { - try { - const status = await client.getSubscriptionStatus(); - return { - success: true, - status, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get subscription status', - }; - } - }); - - // Open Stripe billing portal - ipcMain.handle('subscription:openPortal', async (_event, returnUrl: string) => { - try { - const { url } = await client.createPortalSession(returnUrl); - void shell.openExternal(url); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to open billing portal', - }; - } - }); - - // Open checkout (placeholder - opens pricing page) - ipcMain.handle('subscription:openCheckout', async () => { - try { - void shell.openExternal('https://readied.app/pricing'); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to open checkout', - }; - } + defineIpcHandler({ + channel: 'subscription:getStatus', + args: z.tuple([]), + handler: async () => { + try { + const status = await client.getSubscriptionStatus(); + return { success: true, status }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get subscription status', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'subscription:openPortal', + args: z.tuple([UrlSchema]), + handler: async returnUrl => { + try { + const { url } = await client.createPortalSession(returnUrl); + void shell.openExternal(url); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open billing portal', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'subscription:openCheckout', + args: z.tuple([]), + handler: () => { + try { + void shell.openExternal('https://readied.app/pricing'); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open checkout', + }; + } + }, }); // ═══════════════════════════════════════════════════════════════════════════ // Devices // ═══════════════════════════════════════════════════════════════════════════ - ipcMain.handle('devices:list', async () => { - try { - const result = await client.listDevices(); - return result.devices; - } catch (_error) { - return []; - } - }); - - ipcMain.handle('devices:rename', async (_event, deviceId: string, name: string) => { - try { - await client.renameDevice(deviceId, name); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to rename device', - }; - } - }); - - ipcMain.handle('devices:revoke', async (_event, deviceId: string) => { - try { - await client.revokeDevice(deviceId); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to revoke device', - }; - } - }); - - ipcMain.handle('devices:revokeOthers', async () => { - try { - const result = await client.revokeOtherDevices(); - return { success: true, revokedCount: result.revokedCount }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to revoke devices', - }; - } - }); - - ipcMain.handle('devices:getCurrent', async () => { - try { - const result = await client.listDevices(); - return result.devices.find(d => d.isCurrent) ?? null; - } catch (_error) { - return null; - } + defineIpcHandler({ + channel: 'devices:list', + args: z.tuple([]), + handler: async () => { + try { + const result = await client.listDevices(); + return result.devices; + } catch { + return []; + } + }, + }); + + defineIpcHandler({ + channel: 'devices:rename', + args: z.tuple([IdSchema, NameSchema]), + handler: async (deviceId, name) => { + try { + await client.renameDevice(deviceId, name); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename device', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'devices:revoke', + args: z.tuple([IdSchema]), + handler: async deviceId => { + try { + await client.revokeDevice(deviceId); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke device', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'devices:revokeOthers', + args: z.tuple([]), + handler: async () => { + try { + const result = await client.revokeOtherDevices(); + return { success: true, revokedCount: result.revokedCount }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to revoke devices', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'devices:getCurrent', + args: z.tuple([]), + handler: async () => { + try { + const result = await client.listDevices(); + return result.devices.find(d => d.isCurrent) ?? null; + } catch { + return null; + } + }, }); // ═══════════════════════════════════════════════════════════════════════════ - // Encryption Key Management + // Encryption Key Management (export/import) // ═══════════════════════════════════════════════════════════════════════════ - // Export encryption key (for backup) - ipcMain.handle('encryption:exportKey', async () => { - try { - if (!encryption) { - throw new Error('Encryption service not initialized'); - } - const keyHex = encryption.exportKey(); - return { - success: true, - key: keyHex, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to export encryption key', - }; - } - }); - - // Import encryption key (for restore) - ipcMain.handle('encryption:importKey', async (_event, keyHex: string) => { - try { - if (!encryption) { - throw new Error('Encryption service not initialized'); - } - await encryption.importKey(keyHex); - return { - success: true, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to import encryption key', - }; - } + defineIpcHandler({ + channel: 'encryption:exportKey', + args: z.tuple([]), + handler: () => { + try { + if (!encryption) throw new Error('Encryption service not initialized'); + const keyHex = encryption.exportKey(); + return { success: true, key: keyHex }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to export encryption key', + }; + } + }, + }); + + defineIpcHandler({ + channel: 'encryption:importKey', + args: z.tuple([KeyHexSchema]), + handler: async keyHex => { + try { + if (!encryption) throw new Error('Encryption service not initialized'); + await encryption.importKey(keyHex); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to import encryption key', + }; + } + }, }); } diff --git a/apps/desktop/src/main/handlers/dataHandlers.ts b/apps/desktop/src/main/handlers/dataHandlers.ts index 7177ce7b..b2f7d1d7 100644 --- a/apps/desktop/src/main/handlers/dataHandlers.ts +++ b/apps/desktop/src/main/handlers/dataHandlers.ts @@ -6,7 +6,9 @@ import { join } from 'path'; import { writeFile } from 'fs/promises'; +import { copyFileSync, existsSync, unlinkSync } from 'fs'; import { ipcMain, dialog, shell, app } from 'electron'; +import { z } from 'zod'; import { createBackup, listBackups, @@ -19,6 +21,7 @@ import { import { createDatabase, allMigrations } from '@readied/storage-sqlite'; import { runMigrations } from '@readied/storage-core'; import { createNoteOperation } from '@readied/core'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { SQLiteNoteRepository, Database } from './types.js'; export interface DataHandlerDeps { @@ -33,81 +36,148 @@ export interface DataHandlerDeps { export function registerDataHandlers(deps: DataHandlerDeps): void { const { dataPaths: paths, noteRepository: repo, getDb, setDb } = deps; - // Create backup - ipcMain.handle('data:backup', async () => { - return createBackup({ - backupDir: paths.backups, - databasePath: paths.database, - }); + defineIpcHandler({ + channel: 'data:backup', + args: z.tuple([]), + handler: () => + createBackup({ + backupDir: paths.backups, + databasePath: paths.database, + }), }); - // List backups - ipcMain.handle('data:backups:list', async () => { - return listBackups(paths.backups); + defineIpcHandler({ + channel: 'data:backups:list', + args: z.tuple([]), + handler: () => listBackups(paths.backups), }); - // Restore from backup + // 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) => { - // Close current database connection const currentDb = getDb(); if (currentDb) { currentDb.close(); } + // Copies backup over the live db file and writes a `.pre-restore` safety + // copy of the previous live db (used by the rollback path below). const result = restoreBackup(backupPath, paths.database); + if (!result.success) { + // restoreBackup never touched the live db, just reopen it. + setDb(createDatabase(paths.database)); + return result; + } - // Reconnect to database - const newDb = createDatabase(paths.database); - runMigrations(newDb, allMigrations); - setDb(newDb); + const safetyPath = paths.database + '.pre-restore'; + const rollback = (reason: string): typeof result => { + if (existsSync(safetyPath)) { + copyFileSync(safetyPath, paths.database); + } + setDb(createDatabase(paths.database)); + return { success: false, error: reason }; + }; - return result; - }); + let newDb: ReturnType; + try { + newDb = createDatabase(paths.database); + } catch (err) { + return rollback( + `Could not open restored database: ${err instanceof Error ? err.message : String(err)}` + ); + } - // Export notes - ipcMain.handle('data:export', async () => { - // Show save dialog - const { filePath, canceled } = await dialog.showSaveDialog({ - title: 'Export Notes', - defaultPath: join(app.getPath('documents'), 'readied-export'), - buttonLabel: 'Export', - }); - - if (canceled || !filePath) { - return { success: false, error: 'Export cancelled' }; + // PRAGMA integrity_check returns a single row `{ integrity_check: 'ok' }` + // on a healthy database, or one or more rows describing the corruption. + // We refuse to swap to a corrupt restore and roll back to the safety copy. + try { + const row = newDb.prepare<{ integrity_check: string }>('PRAGMA integrity_check').get(); + if (row?.integrity_check !== 'ok') { + newDb.close(); + return rollback( + `Backup failed integrity check (${row?.integrity_check ?? 'unknown error'}). Previous database has been restored.` + ); + } + } catch (err) { + newDb.close(); + return rollback( + `Backup integrity check threw: ${err instanceof Error ? err.message : String(err)}. Previous database has been restored.` + ); } - // Get all notes - const notes = await repo.list({ archived: 'all' }); - const snapshots = notes.map(note => ({ - id: note.id, - content: note.content, - title: note.title, // Use structural title - createdAt: note.metadata.createdAt, - updatedAt: note.metadata.updatedAt, - tags: [...note.metadata.tags], - wordCount: note.metadata.wordCount, - archivedAt: note.metadata.archivedAt, - })); - - const result = exportNotes(snapshots, { - outputDir: filePath, - appVersion: app.getVersion(), - includeArchived: true, - }); - - if (result.success) { - // Open the export folder - shell.showItemInFolder(filePath); + // Backup is intact — apply migrations to bring older schemas current. + try { + runMigrations(newDb, allMigrations); + } catch (err) { + newDb.close(); + return rollback( + `Migrations failed on restored database: ${err instanceof Error ? err.message : String(err)}. Previous database has been restored.` + ); + } + + setDb(newDb); + + // Restore succeeded — discard the safety copy. + if (existsSync(safetyPath)) { + try { + unlinkSync(safetyPath); + } catch { + // best-effort cleanup; not fatal + } } return result; }); - // Export single note to file - ipcMain.handle( - 'data:exportNote', - async (_event: Electron.IpcMainInvokeEvent, content: string, suggestedName: string) => { + defineIpcHandler({ + channel: 'data:export', + args: z.tuple([]), + handler: async () => { + // Show save dialog + const { filePath, canceled } = await dialog.showSaveDialog({ + title: 'Export Notes', + defaultPath: join(app.getPath('documents'), 'readied-export'), + buttonLabel: 'Export', + }); + + if (canceled || !filePath) { + return { success: false, error: 'Export cancelled' }; + } + + // Get all notes + const notes = await repo.list({ archived: 'all' }); + const snapshots = notes.map(note => ({ + id: note.id, + content: note.content, + title: note.title, // Use structural title + createdAt: note.metadata.createdAt, + updatedAt: note.metadata.updatedAt, + tags: [...note.metadata.tags], + wordCount: note.metadata.wordCount, + archivedAt: note.metadata.archivedAt, + })); + + const result = exportNotes(snapshots, { + outputDir: filePath, + appVersion: app.getVersion(), + includeArchived: true, + }); + + if (result.success) { + shell.showItemInFolder(filePath); + } + + return result; + }, + }); + + defineIpcHandler({ + channel: 'data:exportNote', + args: z.tuple([z.string().max(1024 * 1024), z.string().max(512)]), + handler: async (content, suggestedName) => { let safeName = suggestedName .normalize('NFC') @@ -137,71 +207,78 @@ export function registerDataHandlers(deps: DataHandlerDeps): void { error: error instanceof Error ? error.message : 'Failed to write file', }; } - } - ); - - // Import notes - ipcMain.handle('data:import', async () => { - // Show folder selection dialog - const { filePaths, canceled } = await dialog.showOpenDialog({ - title: 'Import Notes', - properties: ['openDirectory'], - buttonLabel: 'Import', - }); - - const sourceDir = filePaths[0]; - if (canceled || !sourceDir) { - return { success: false, error: 'Import cancelled' }; - } + }, + }); - const importType = detectImportType(sourceDir); + defineIpcHandler({ + channel: 'data:import', + args: z.tuple([]), + handler: async () => { + // Show folder selection dialog + const { filePaths, canceled } = await dialog.showOpenDialog({ + title: 'Import Notes', + properties: ['openDirectory'], + buttonLabel: 'Import', + }); - const result = importNotes({ - sourceDir, - type: importType, - recursive: true, - }); + const sourceDir = filePaths[0]; + if (canceled || !sourceDir) { + return { success: false, error: 'Import cancelled' }; + } - if (!result.success || !result.notes) { - return result; - } + const importType = detectImportType(sourceDir); - // Import each note - let imported = 0; - for (const imported_note of result.notes) { - try { - await createNoteOperation( - { - content: imported_note.content, - }, - repo - ); - imported++; - } catch { - // Skip notes that fail to import + const result = importNotes({ + sourceDir, + type: importType, + recursive: true, + }); + + if (!result.success || !result.notes) { + return result; } - } - return { - success: true, - noteCount: imported, - skipped: result.skipped, - }; + // Import each note + let imported = 0; + for (const imported_note of result.notes) { + try { + await createNoteOperation( + { + content: imported_note.content, + }, + repo + ); + imported++; + } catch { + // Skip notes that fail to import + } + } + + return { + success: true, + noteCount: imported, + skipped: result.skipped, + }; + }, }); - // Get data paths info - ipcMain.handle('data:paths', async () => { - return { + defineIpcHandler({ + channel: 'data:paths', + args: z.tuple([]), + handler: () => ({ root: paths.root, database: paths.database, backups: paths.backups, logs: paths.logs, - }; + }), }); - // Open data folder in system file manager - ipcMain.handle('data:openFolder', async () => { - void shell.openPath(paths.root); - return { success: true }; + defineIpcHandler({ + channel: 'data:openFolder', + args: z.tuple([]), + handler: () => { + void shell.openPath(paths.root); + return { success: true }; + }, }); } diff --git a/apps/desktop/src/main/handlers/gitHandlers.ts b/apps/desktop/src/main/handlers/gitHandlers.ts index c99520d4..431896d0 100644 --- a/apps/desktop/src/main/handlers/gitHandlers.ts +++ b/apps/desktop/src/main/handlers/gitHandlers.ts @@ -4,113 +4,133 @@ * Handles git operations for git-backed notebooks. */ -import { ipcMain } from 'electron'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { GitService } from './types.js'; export interface GitHandlerDeps { gitService: GitService; } +const IdSchema = z.string().min(1).max(128); +// SHAs are hex; allow short-SHAs (≥7) up to full 40-char. +const ShaSchema = z + .string() + .min(7) + .max(40) + .regex(/^[a-f0-9]+$/i); +// Commit messages can be long but not absurd. +const CommitMessageSchema = z.string().min(1).max(8192); +// Note file content cap matches the share payload cap. +const NoteContentSchema = z.string().max(1024 * 1024); + export function registerGitHandlers(deps: GitHandlerDeps): void { const { gitService: git } = deps; - // Initialize git repository for a notebook - ipcMain.handle('git:init', async (_event, notebookId: string) => { - try { - const repoPath = await git.initRepository(notebookId); - return { - success: true, - repoPath, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to initialize git repository', - }; - } + defineIpcHandler({ + channel: 'git:init', + args: z.tuple([IdSchema]), + handler: async notebookId => { + try { + const repoPath = await git.initRepository(notebookId); + return { success: true, repoPath }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to initialize git repository', + }; + } + }, }); - // Check if notebook has git repository - ipcMain.handle('git:isRepo', async (_event, notebookId: string) => { - try { - const isRepo = await git.isGitRepository(notebookId); - return { success: true, isRepo }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to check git repository', - }; - } + defineIpcHandler({ + channel: 'git:isRepo', + args: z.tuple([IdSchema]), + handler: async notebookId => { + try { + const isRepo = await git.isGitRepository(notebookId); + return { success: true, isRepo }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check git repository', + }; + } + }, }); - // Commit changes - ipcMain.handle( - 'git:commit', - async (_event, notebookId: string, message: string, files?: string[]) => { + defineIpcHandler({ + channel: 'git:commit', + args: z.tuple([ + IdSchema, + CommitMessageSchema, + z.array(z.string().max(1024)).max(10000).optional(), + ]), + handler: async (notebookId, message, files) => { try { const sha = await git.commit(notebookId, message, files); - return { - success: true, - sha, - }; + return { success: true, sha }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to commit changes', }; } - } - ); + }, + }); - // Get commit history - ipcMain.handle('git:log', async (_event, notebookId: string, limit?: number) => { - try { - const commits = await git.log(notebookId, limit); - return { - success: true, - commits, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get commit history', - }; - } + defineIpcHandler({ + channel: 'git:log', + args: z.tuple([IdSchema, z.number().int().positive().max(10000).optional()]), + handler: async (notebookId, limit) => { + try { + const commits = await git.log(notebookId, limit); + return { success: true, commits }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get commit history', + }; + } + }, }); - // Get repository status - ipcMain.handle('git:status', async (_event, notebookId: string) => { - try { - const status = await git.status(notebookId); - return { - success: true, - status, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get repository status', - }; - } + defineIpcHandler({ + channel: 'git:status', + args: z.tuple([IdSchema]), + handler: async notebookId => { + try { + const status = await git.status(notebookId); + return { success: true, status }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get repository status', + }; + } + }, }); - // Checkout (revert to) a specific commit - ipcMain.handle('git:checkout', async (_event, notebookId: string, commitSha: string) => { - try { - await git.checkout(notebookId, commitSha); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to checkout commit', - }; - } + defineIpcHandler({ + channel: 'git:checkout', + args: z.tuple([IdSchema, ShaSchema]), + handler: async (notebookId, commitSha) => { + try { + await git.checkout(notebookId, commitSha); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to checkout commit', + }; + } + }, }); - // Write note file to git repository - ipcMain.handle( - 'git:writeNote', - async (_event, notebookId: string, noteId: string, content: string) => { + defineIpcHandler({ + channel: 'git:writeNote', + args: z.tuple([IdSchema, IdSchema, NoteContentSchema]), + handler: async (notebookId, noteId, content) => { try { await git.writeNoteFile(notebookId, noteId, content); return { success: true }; @@ -120,35 +140,38 @@ export function registerGitHandlers(deps: GitHandlerDeps): void { error: error instanceof Error ? error.message : 'Failed to write note file', }; } - } - ); + }, + }); - // Read note file from git repository - ipcMain.handle('git:readNote', async (_event, notebookId: string, noteId: string) => { - try { - const content = await git.readNoteFile(notebookId, noteId); - return { - success: true, - content, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to read note file', - }; - } + defineIpcHandler({ + channel: 'git:readNote', + args: z.tuple([IdSchema, IdSchema]), + handler: async (notebookId, noteId) => { + try { + const content = await git.readNoteFile(notebookId, noteId); + return { success: true, content }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read note file', + }; + } + }, }); - // Delete note file from git repository - ipcMain.handle('git:deleteNote', async (_event, notebookId: string, noteId: string) => { - try { - await git.deleteNoteFile(notebookId, noteId); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to delete note file', - }; - } + defineIpcHandler({ + channel: 'git:deleteNote', + args: z.tuple([IdSchema, IdSchema]), + handler: async (notebookId, noteId) => { + try { + await git.deleteNoteFile(notebookId, noteId); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete note file', + }; + } + }, }); } diff --git a/apps/desktop/src/main/handlers/licenseHandlers.ts b/apps/desktop/src/main/handlers/licenseHandlers.ts index 5d169c81..479cf0b7 100644 --- a/apps/desktop/src/main/handlers/licenseHandlers.ts +++ b/apps/desktop/src/main/handlers/licenseHandlers.ts @@ -5,7 +5,8 @@ * Fetches subscription status from API with local caching. */ -import { ipcMain, shell } from 'electron'; +import { shell } from 'electron'; +import { z } from 'zod'; import type { LicenseStorage, AppLicenseState, @@ -18,6 +19,7 @@ import { canStartTrial, isCachedSubscriptionValid, } from '@readied/licensing'; +import { defineIpcHandler } from '../ipc/registry.js'; import { loggers } from '../logger'; import type { ApiClient, SubscriptionStatus } from '../services/apiClient'; @@ -116,62 +118,62 @@ async function getSubscriptionData( export function registerLicenseHandlers(deps: LicenseHandlerDependencies): void { const { licenseStorage, apiClient } = deps; - /** - * Get current license state - * Trial data is local, subscription data comes from server (with local cache) - */ - ipcMain.handle('license:getState', async (): Promise => { - let trialData = await licenseStorage.readTrialData(); - const subscriptionData = await getSubscriptionData(licenseStorage, apiClient); - - // Auto-start trial if user hasn't started one yet - if (canStartTrial(trialData, subscriptionData)) { - trialData = startTrial(); - await licenseStorage.writeTrialData(trialData); - getLicenseLogger().info('Trial started automatically'); - } + defineIpcHandler({ + channel: 'license:getState', + args: z.tuple([]), + handler: async (): Promise => { + let trialData = await licenseStorage.readTrialData(); + const subscriptionData = await getSubscriptionData(licenseStorage, apiClient); + + if (canStartTrial(trialData, subscriptionData)) { + trialData = startTrial(); + await licenseStorage.writeTrialData(trialData); + getLicenseLogger().info('Trial started automatically'); + } - return computeLicenseState(trialData, subscriptionData); + return computeLicenseState(trialData, subscriptionData); + }, }); - /** - * Force-refresh subscription status from API (ignores cache) - */ - ipcMain.handle('license:refreshSubscription', async (): Promise => { - const trialData = await licenseStorage.readTrialData(); - const subscriptionData = await getSubscriptionData(licenseStorage, apiClient, true); - return computeLicenseState(trialData, subscriptionData); + defineIpcHandler({ + channel: 'license:refreshSubscription', + args: z.tuple([]), + handler: async (): Promise => { + const trialData = await licenseStorage.readTrialData(); + const subscriptionData = await getSubscriptionData(licenseStorage, apiClient, true); + return computeLicenseState(trialData, subscriptionData); + }, }); - /** - * Start trial manually (if not auto-started) - */ - ipcMain.handle('license:startTrial', async (): Promise<{ success: boolean; error?: string }> => { - const trialData = await licenseStorage.readTrialData(); - const subscriptionData = await licenseStorage.readSubscriptionData(); + defineIpcHandler({ + channel: 'license:startTrial', + args: z.tuple([]), + handler: async (): Promise<{ success: boolean; error?: string }> => { + const trialData = await licenseStorage.readTrialData(); + const subscriptionData = await licenseStorage.readSubscriptionData(); - if (!canStartTrial(trialData, subscriptionData)) { - return { success: false, error: 'Trial already started or subscription active' }; - } + if (!canStartTrial(trialData, subscriptionData)) { + return { success: false, error: 'Trial already started or subscription active' }; + } - const newTrialData = startTrial(); - await licenseStorage.writeTrialData(newTrialData); - getLicenseLogger().info('Trial started manually'); - return { success: true }; + const newTrialData = startTrial(); + await licenseStorage.writeTrialData(newTrialData); + getLicenseLogger().info('Trial started manually'); + return { success: true }; + }, }); - /** - * Open subscription checkout page - * Creates a Stripe checkout session via API and opens it in the browser - */ - ipcMain.handle( - 'license:openSubscribe', - async ( - _event, - options?: { plan?: 'monthly' | 'annual' } - ): Promise<{ success: boolean; error?: string }> => { + defineIpcHandler({ + channel: 'license:openSubscribe', + args: z.tuple([ + z + .object({ + plan: z.enum(['monthly', 'annual']).optional(), + }) + .optional(), + ]), + handler: async (options): Promise<{ success: boolean; error?: string }> => { try { - // Get current user to verify authentication const user = await apiClient.getCurrentUser(); if (!user || !user.email) { @@ -184,7 +186,6 @@ export function registerLicenseHandlers(deps: LicenseHandlerDependencies): void 'Creating checkout session via API' ); - // Create checkout session via API (server handles Stripe SDK) const { url } = await apiClient.createCheckoutSession({ plan: options?.plan || 'monthly', successUrl: 'https://readied.app/subscription/success', @@ -195,7 +196,6 @@ export function registerLicenseHandlers(deps: LicenseHandlerDependencies): void return { success: false, error: 'No checkout URL returned' }; } - // Open checkout URL in browser await shell.openExternal(url); getLicenseLogger().info({ email: user.email }, 'Checkout session opened in browser'); @@ -207,6 +207,6 @@ export function registerLicenseHandlers(deps: LicenseHandlerDependencies): void error: error instanceof Error ? error.message : 'Failed to create checkout session', }; } - } - ); + }, + }); } diff --git a/apps/desktop/src/main/handlers/localServerHandlers.ts b/apps/desktop/src/main/handlers/localServerHandlers.ts index 78435fe7..9e6eb3f3 100644 --- a/apps/desktop/src/main/handlers/localServerHandlers.ts +++ b/apps/desktop/src/main/handlers/localServerHandlers.ts @@ -5,13 +5,15 @@ * to the renderer (settings UI). */ -import { ipcMain, app } from 'electron'; +import { app } from 'electron'; +import { z } from 'zod'; import { createNoteId, createNoteOperation, updateNoteOperation } from '@readied/core'; import { LocalServer, getOrCreateApiToken, type LocalServerHandlers, } from '../services/localServer.js'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { SQLiteNoteRepository, DataPaths } from './types.js'; // ============================================================================ @@ -131,49 +133,56 @@ export function registerLocalServerHandlers(deps: LocalServerHandlerDeps): void }, }; - // IPC: Start the local server - ipcMain.handle('localServer:start', async (_event, port?: number) => { - try { - if (port !== undefined && (typeof port !== 'number' || port < 1 || port > 65535)) { - return { ok: false, error: 'Invalid port' }; + defineIpcHandler({ + channel: 'localServer:start', + args: z.tuple([z.number().int().min(1).max(65535).optional()]), + handler: async port => { + try { + if (server.isRunning()) return { ok: true, port: server.getPort() }; + apiToken = await getOrCreateApiToken(dataPaths.root); + await server.start(port, apiToken, handlers); + return { ok: true, port: server.getPort() }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; } - if (server.isRunning()) return { ok: true, port: server.getPort() }; - apiToken = await getOrCreateApiToken(dataPaths.root); - await server.start(port, apiToken, handlers); - return { ok: true, port: server.getPort() }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + }, }); - // IPC: Stop the local server - ipcMain.handle('localServer:stop', async () => { - try { - await server.stop(); - return { ok: true }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + defineIpcHandler({ + channel: 'localServer:stop', + args: z.tuple([]), + handler: async () => { + try { + await server.stop(); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, }); - // IPC: Get server status - ipcMain.handle('localServer:status', () => { - return { + defineIpcHandler({ + channel: 'localServer:status', + args: z.tuple([]), + handler: () => ({ running: server.isRunning(), port: server.getPort(), - }; + }), }); - // IPC: Get the bearer token (for displaying in settings) - ipcMain.handle('localServer:getToken', async () => { - try { - if (!apiToken) { - apiToken = await getOrCreateApiToken(dataPaths.root); + defineIpcHandler({ + channel: 'localServer:getToken', + args: z.tuple([]), + handler: async () => { + try { + if (!apiToken) { + apiToken = await getOrCreateApiToken(dataPaths.root); + } + return { ok: true, value: apiToken }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; } - return { ok: true, value: apiToken }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } + }, }); } diff --git a/apps/desktop/src/main/handlers/logHandlers.ts b/apps/desktop/src/main/handlers/logHandlers.ts index 8e478822..c629706e 100644 --- a/apps/desktop/src/main/handlers/logHandlers.ts +++ b/apps/desktop/src/main/handlers/logHandlers.ts @@ -1,11 +1,14 @@ /** * Log IPC Handlers * - * Handles renderer-side logging via IPC. + * Handles renderer-side logging via IPC. Validated at the boundary: + * level must be one of the enum values; message capped at 16 KiB; + * context object size is left to JSON serialization limits. */ -import { ipcMain } from 'electron'; -import { createChildLogger, type LogLevel } from '../logger'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; +import { createChildLogger } from '../logger'; import type { DataPaths } from './types.js'; export interface LogHandlerDeps { @@ -13,20 +16,18 @@ export interface LogHandlerDeps { getDataPaths: () => DataPaths | null; } +const LogLevelSchema = z.enum(['debug', 'info', 'warn', 'error']); +const LogMessageSchema = z.string().max(16384); +const LogContextSchema = z.record(z.string(), z.unknown()).optional(); + export function registerLogHandlers(deps: LogHandlerDeps): void { const rendererLogger = createChildLogger({ component: 'renderer' }); - // Log from renderer - ipcMain.handle( - 'log:write', - async ( - _event, - level: LogLevel, - message: string, - context?: Record - ): Promise<{ success: boolean }> => { + defineIpcHandler({ + channel: 'log:write', + args: z.tuple([LogLevelSchema, LogMessageSchema, LogContextSchema]), + handler: (level, message, context): { success: boolean } => { const childLogger = context ? rendererLogger.child(context) : rendererLogger; - switch (level) { case 'debug': childLogger.debug(message); @@ -41,13 +42,13 @@ export function registerLogHandlers(deps: LogHandlerDeps): void { childLogger.error(message); break; } - return { success: true }; - } - ); + }, + }); - // Get log file path (for debugging/support) - ipcMain.handle('log:getPath', async (): Promise => { - return deps.getDataPaths()?.logs ?? null; + defineIpcHandler({ + channel: 'log:getPath', + args: z.tuple([]), + handler: (): string | null => deps.getDataPaths()?.logs ?? null, }); } diff --git a/apps/desktop/src/main/handlers/noteHandlers.ts b/apps/desktop/src/main/handlers/noteHandlers.ts index 329c1927..e1412c89 100644 --- a/apps/desktop/src/main/handlers/noteHandlers.ts +++ b/apps/desktop/src/main/handlers/noteHandlers.ts @@ -7,7 +7,7 @@ import { join } from 'path'; import { existsSync } from 'fs'; import { mkdir, writeFile } from 'fs/promises'; -import { ipcMain } from 'electron'; +import { z } from 'zod'; import { createNoteOperation, updateNoteOperation, @@ -26,6 +26,7 @@ import { type NoteStatus, } from '@readied/core'; import { createNoteId, createNotebookId, createTag } from '@readied/core'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { SQLiteNoteRepository, DataPaths, NoteToSnapshotFn } from './types.js'; export interface NoteHandlerDeps { @@ -34,298 +35,327 @@ export interface NoteHandlerDeps { noteToSnapshot: NoteToSnapshotFn; } +const IdSchema = z.string().min(1).max(128); +const TitleSchema = z.string().max(512); +const ContentSchema = z.string().max(10 * 1024 * 1024); // 10 MiB cap on note content +const TagSchema = z.string().min(1).max(64); +const StatusSchema: z.ZodType = z.enum(['active', 'on_hold', 'completed', 'dropped']); + export function registerNoteHandlers(deps: NoteHandlerDeps): void { const { noteRepository: repo, dataPaths, noteToSnapshot } = deps; - // Create note - ipcMain.handle( - 'notes:create', - async (_event, input: { content: string; id?: string; notebookId?: string }) => { - return createNoteOperation(input, repo); - } - ); - - // Get note - ipcMain.handle('notes:get', async (_event, id: string) => { - const noteId = createNoteId(id); - return getNoteOperation({ id: noteId }, repo); + // ── Notes CRUD ────────────────────────────────────────────────────────── + + defineIpcHandler({ + channel: 'notes:create', + args: z.tuple([ + z.object({ + content: ContentSchema, + id: IdSchema.optional(), + notebookId: IdSchema.optional(), + }), + ]), + handler: input => createNoteOperation(input, repo), }); - // Update note content - ipcMain.handle('notes:update', async (_event, input: { id: string; content: string }) => { - const noteId = createNoteId(input.id); - return updateNoteOperation({ id: noteId, content: input.content }, repo); + defineIpcHandler({ + channel: 'notes:get', + args: z.tuple([IdSchema]), + handler: id => getNoteOperation({ id: createNoteId(id) }, repo), }); - // Update note title (structural, independent from content) - ipcMain.handle('notes:updateTitle', async (_event, input: { id: string; title: string }) => { - const noteId = createNoteId(input.id); - return updateTitleOperation({ id: noteId, title: input.title }, repo); + defineIpcHandler({ + channel: 'notes:update', + args: z.tuple([z.object({ id: IdSchema, content: ContentSchema })]), + handler: input => + updateNoteOperation({ id: createNoteId(input.id), content: input.content }, repo), }); - // Delete note - ipcMain.handle('notes:delete', async (_event, id: string) => { - const noteId = createNoteId(id); - return deleteNoteOperation({ id: noteId }, repo); + defineIpcHandler({ + channel: 'notes:updateTitle', + args: z.tuple([z.object({ id: IdSchema, title: TitleSchema })]), + handler: input => + updateTitleOperation({ id: createNoteId(input.id), title: input.title }, repo), }); - // Archive note - ipcMain.handle('notes:archive', async (_event, id: string) => { - const noteId = createNoteId(id); - return archiveNoteOperation({ id: noteId }, repo); + defineIpcHandler({ + channel: 'notes:delete', + args: z.tuple([IdSchema]), + handler: id => deleteNoteOperation({ id: createNoteId(id) }, repo), }); - // Restore note - ipcMain.handle('notes:restore', async (_event, id: string) => { - const noteId = createNoteId(id); - return restoreNoteOperation({ id: noteId }, repo); + defineIpcHandler({ + channel: 'notes:archive', + args: z.tuple([IdSchema]), + handler: id => archiveNoteOperation({ id: createNoteId(id) }, repo), }); - // Duplicate note - ipcMain.handle('notes:duplicate', async (_event, id: string) => { - const noteId = createNoteId(id); - return duplicateNoteOperation({ id: noteId }, repo); + defineIpcHandler({ + channel: 'notes:restore', + args: z.tuple([IdSchema]), + handler: id => restoreNoteOperation({ id: createNoteId(id) }, repo), }); - // Move note to notebook - ipcMain.handle('notes:move', async (_event, noteId: string, notebookId: string) => { - const note = await repo.get(createNoteId(noteId)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id: noteId } }; - } - - const movedNote = moveNoteToNotebook(note, createNotebookId(notebookId)); - await repo.save(movedNote); - - return { - ok: true, - data: noteToSnapshot(movedNote), - }; + defineIpcHandler({ + channel: 'notes:duplicate', + args: z.tuple([IdSchema]), + handler: id => duplicateNoteOperation({ id: createNoteId(id) }, repo), }); - // Pin note - ipcMain.handle('notes:pin', async (_event, id: string) => { - const note = await repo.get(createNoteId(id)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id } }; - } - - const pinnedNote = pinNote(note); - await repo.save(pinnedNote); - - return { ok: true, data: noteToSnapshot(pinnedNote) }; + defineIpcHandler({ + channel: 'notes:move', + args: z.tuple([IdSchema, IdSchema]), + handler: async (noteId, notebookId) => { + const note = await repo.get(createNoteId(noteId)); + if (!note) { + return { ok: false, error: { type: 'NOT_FOUND', id: noteId } }; + } + const movedNote = moveNoteToNotebook(note, createNotebookId(notebookId)); + await repo.save(movedNote); + return { ok: true, data: noteToSnapshot(movedNote) }; + }, }); - // Unpin note - ipcMain.handle('notes:unpin', async (_event, id: string) => { - const note = await repo.get(createNoteId(id)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id } }; - } - - const unpinnedNote = unpinNote(note); - await repo.save(unpinnedNote); - - return { ok: true, data: noteToSnapshot(unpinnedNote) }; + defineIpcHandler({ + channel: 'notes:pin', + args: z.tuple([IdSchema]), + handler: async id => { + const note = await repo.get(createNoteId(id)); + if (!note) return { ok: false, error: { type: 'NOT_FOUND', id } }; + const pinnedNote = pinNote(note); + await repo.save(pinnedNote); + return { ok: true, data: noteToSnapshot(pinnedNote) }; + }, }); - // Soft delete (move to trash) - ipcMain.handle('notes:softDelete', async (_event, id: string) => { - const note = await repo.get(createNoteId(id)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id } }; - } - - const deletedNote = softDeleteNote(note); - await repo.save(deletedNote); - - return { ok: true, data: noteToSnapshot(deletedNote) }; + defineIpcHandler({ + channel: 'notes:unpin', + args: z.tuple([IdSchema]), + handler: async id => { + const note = await repo.get(createNoteId(id)); + if (!note) return { ok: false, error: { type: 'NOT_FOUND', id } }; + const unpinnedNote = unpinNote(note); + await repo.save(unpinnedNote); + return { ok: true, data: noteToSnapshot(unpinnedNote) }; + }, }); - // Restore from trash - ipcMain.handle('notes:restoreDeleted', async (_event, id: string) => { - const note = await repo.get(createNoteId(id)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id } }; - } - - const restoredNote = restoreDeletedNote(note); - await repo.save(restoredNote); - - return { ok: true, data: noteToSnapshot(restoredNote) }; + defineIpcHandler({ + channel: 'notes:softDelete', + args: z.tuple([IdSchema]), + handler: async id => { + const note = await repo.get(createNoteId(id)); + if (!note) return { ok: false, error: { type: 'NOT_FOUND', id } }; + const deletedNote = softDeleteNote(note); + await repo.save(deletedNote); + return { ok: true, data: noteToSnapshot(deletedNote) }; + }, }); - // Set note status - ipcMain.handle('notes:setStatus', async (_event, id: string, status: NoteStatus) => { - const note = await repo.get(createNoteId(id)); - if (!note) { - return { ok: false, error: { type: 'NOT_FOUND', id } }; - } - - const updatedNote = setNoteStatus(note, status); - await repo.save(updatedNote); + defineIpcHandler({ + channel: 'notes:restoreDeleted', + args: z.tuple([IdSchema]), + handler: async id => { + const note = await repo.get(createNoteId(id)); + if (!note) return { ok: false, error: { type: 'NOT_FOUND', id } }; + const restoredNote = restoreDeletedNote(note); + await repo.save(restoredNote); + return { ok: true, data: noteToSnapshot(restoredNote) }; + }, + }); - return { ok: true, data: noteToSnapshot(updatedNote) }; + defineIpcHandler({ + channel: 'notes:setStatus', + args: z.tuple([IdSchema, StatusSchema]), + handler: async (id, status) => { + const note = await repo.get(createNoteId(id)); + if (!note) return { ok: false, error: { type: 'NOT_FOUND', id } }; + const updatedNote = setNoteStatus(note, status); + await repo.save(updatedNote); + return { ok: true, data: noteToSnapshot(updatedNote) }; + }, }); - // List notes - ipcMain.handle( - 'notes:list', - async ( - _event, - options?: { - limit?: number; - offset?: number; - tag?: string; - sortBy?: 'createdAt' | 'updatedAt' | 'title'; - sortOrder?: 'asc' | 'desc'; - archived?: 'active' | 'archived' | 'all'; - } - ) => { + 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)); + }, }); - // Get all tags - ipcMain.handle('notes:tags', async () => { - return repo.getAllTags(); + // ── Tags ──────────────────────────────────────────────────────────────── + + defineIpcHandler({ + channel: 'notes:tags', + args: z.tuple([]), + handler: () => repo.getAllTags(), }); - // Set manual tags (full replacement) - ipcMain.handle('notes:setManualTags', async (_event, noteId: string, tags: string[]) => { - const id = createNoteId(noteId); - // Normalize tags: trim, lowercase, strip leading '#', remove empties, dedupe - const normalizedTags = [ - ...new Set(tags.map(t => t.trim().toLowerCase().replace(/^#/, '')).filter(t => t.length > 0)), - ]; - repo.setManualTags( - id, - normalizedTags.map(t => createTag(t)) - ); - return { ok: true }; + defineIpcHandler({ + channel: 'notes:setManualTags', + args: z.tuple([IdSchema, z.array(TagSchema).max(256)]), + handler: (noteId, tags) => { + const id = createNoteId(noteId); + const normalizedTags = [ + ...new Set( + tags.map(t => t.trim().toLowerCase().replace(/^#/, '')).filter(t => t.length > 0) + ), + ]; + repo.setManualTags( + id, + normalizedTags.map(t => createTag(t)) + ); + return { ok: true }; + }, }); - // Get manual tags only (for editor to know which are removable) - ipcMain.handle('notes:getManualTags', async (_event, noteId: string) => { - const id = createNoteId(noteId); - return repo.getManualTags(id); + defineIpcHandler({ + channel: 'notes:getManualTags', + args: z.tuple([IdSchema]), + handler: noteId => repo.getManualTags(createNoteId(noteId)), }); - // Get all tags with colors - ipcMain.handle('tags:listWithColors', async () => { - return repo.getAllTagsWithColors(); + defineIpcHandler({ + channel: 'tags:listWithColors', + args: z.tuple([]), + handler: () => repo.getAllTagsWithColors(), }); - // Set tag color - ipcMain.handle('tags:setColor', async (_event, tagName: string, color: string | null) => { - repo.setTagColor(tagName, color); - return { ok: true }; + defineIpcHandler({ + channel: 'tags:setColor', + args: z.tuple([TagSchema, z.string().max(32).nullable()]), + handler: (tagName, color) => { + repo.setTagColor(tagName, color); + return { ok: true }; + }, }); - // Delete tag from system - ipcMain.handle('tags:delete', async (_event, tagName: string) => { - repo.deleteTag(tagName); - return { ok: true }; + defineIpcHandler({ + channel: 'tags:delete', + args: z.tuple([TagSchema]), + handler: tagName => { + repo.deleteTag(tagName); + return { ok: true }; + }, }); - // Rename tag across all notes - ipcMain.handle('tags:rename', async (_event, oldName: string, newName: string) => { - return repo.renameTag(oldName, newName); + defineIpcHandler({ + channel: 'tags:rename', + args: z.tuple([TagSchema, TagSchema]), + handler: (oldName, newName) => repo.renameTag(oldName, newName), }); - // ═══════════════════════════════════════════════════════════════════════════ - // Links (Wikilinks / Backlinks) - // ═══════════════════════════════════════════════════════════════════════════ + // ── Links (Wikilinks / Backlinks) ─────────────────────────────────────── - // Sync links for a note (call after saving note) - ipcMain.handle('links:sync', async (_event, noteId: string, content: string) => { - repo.syncLinks(createNoteId(noteId), content); - return { ok: true }; + defineIpcHandler({ + channel: 'links:sync', + args: z.tuple([IdSchema, ContentSchema]), + handler: (noteId, content) => { + repo.syncLinks(createNoteId(noteId), content); + return { ok: true }; + }, }); - // Get backlinks (notes that link TO this note) - ipcMain.handle('links:backlinks', async (_event, noteId: string) => { - return repo.getBacklinks(createNoteId(noteId)); + defineIpcHandler({ + channel: 'links:backlinks', + args: z.tuple([IdSchema]), + handler: noteId => repo.getBacklinks(createNoteId(noteId)), }); - // Get outgoing links (notes this note links TO) - ipcMain.handle('links:outgoing', async (_event, noteId: string) => { - return repo.getOutgoingLinks(createNoteId(noteId)); + defineIpcHandler({ + channel: 'links:outgoing', + args: z.tuple([IdSchema]), + handler: noteId => repo.getOutgoingLinks(createNoteId(noteId)), }); - // Get graph data (all notes and links for visualization) - ipcMain.handle('links:graph', async () => { - try { - return repo.getGraphData(); - } catch (error) { - console.error('Failed to get graph data:', error); - // Return empty data on error - return { nodes: [], edges: [] }; - } + defineIpcHandler({ + channel: 'links:graph', + args: z.tuple([]), + handler: () => { + try { + return repo.getGraphData(); + } catch (error) { + console.error('Failed to get graph data:', error); + return { nodes: [], edges: [] }; + } + }, }); - // ═══════════════════════════════════════════════════════════════════════════ - // Embeds (File Resolution) - // ═══════════════════════════════════════════════════════════════════════════ - - // Resolve embed target to asset:// URL - ipcMain.handle('embeds:resolve', async (_event, target: string, noteId: string) => { - // Build path to note's assets folder: /assets/{noteId}/{target} - const assetPath = join(dataPaths.assets, noteId, target); - - // Check if file exists - if (existsSync(assetPath)) { - // Return asset:// URL with host (required for browser to recognize protocol) - return `asset://local/${noteId}/${target}`; - } - - // File not found - return null; + // ── Embeds (File Resolution) ──────────────────────────────────────────── + + // Embed targets are filenames inside an asset folder — restrict to + // characters that can appear in a generated asset name (no slashes, no + // path traversal). The relative-path check inside `join` would catch + // most issues but rejecting at the boundary is cheaper. + const EmbedTargetSchema = z + .string() + .min(1) + .max(256) + .regex(/^[a-zA-Z0-9._-]+$/); + + defineIpcHandler({ + channel: 'embeds:resolve', + args: z.tuple([EmbedTargetSchema, IdSchema]), + handler: (target, noteId) => { + const assetPath = join(dataPaths.assets, noteId, target); + return existsSync(assetPath) ? `asset://local/${noteId}/${target}` : null; + }, }); - // Batch resolve multiple embed targets (more efficient) - ipcMain.handle( - 'embeds:resolveBatch', - async (_event, targets: string[], noteId: string): Promise> => { + defineIpcHandler({ + channel: 'embeds:resolveBatch', + args: z.tuple([z.array(EmbedTargetSchema).max(1000), IdSchema]), + handler: (targets, noteId): Record => { const result: Record = {}; for (const target of targets) { const assetPath = join(dataPaths.assets, noteId, target); - // Return asset:// URL with host (required for browser to recognize protocol) result[target] = existsSync(assetPath) ? `asset://local/${noteId}/${target}` : null; } return result; - } - ); - - // Save asset (image/file) for a note via drag & drop or paste - ipcMain.handle( - 'embeds:saveAsset', - async ( - _event, - noteId: string, - mime: string, - bytes: ArrayBuffer, - originalName?: string - ): Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }> => { - // Validate noteId (non-empty, alphanumeric with hyphens/underscores) - if (!noteId || !/^[\w-]+$/.test(noteId)) { - return { ok: false, error: 'Invalid noteId' }; - } + }, + }); - // Validate size (max 20MB) + defineIpcHandler({ + channel: 'embeds:saveAsset', + args: z.tuple([ + IdSchema.regex(/^[\w-]+$/), + z.string().min(1).max(256), + z.instanceof(ArrayBuffer), + z.string().max(512).optional(), + ]), + handler: async ( + noteId, + mime, + bytes, + originalName + ): Promise<{ ok: true; filename: string; relPath: string } | { ok: false; error: string }> => { const MAX_SIZE = 20 * 1024 * 1024; if (bytes.byteLength > MAX_SIZE) { return { ok: false, error: 'File too large (max 20MB)' }; } - // Derive extension from mime type const mimeToExt: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', @@ -346,142 +376,122 @@ export function registerNoteHandlers(deps: NoteHandlerDeps): void { let ext = mimeToExt[mime]; if (!ext && originalName) { - // Fallback to originalName extension const match = originalName.match(/\.([a-zA-Z0-9]+)$/); ext = match?.[1]?.toLowerCase() ?? 'bin'; } - if (!ext) { - ext = 'bin'; - } + if (!ext) ext = 'bin'; - // Generate unique filename: timestamp-random.ext const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); const filename = `${timestamp}-${random}.${ext}`; - // Ensure note's assets directory exists const noteAssetsDir = join(dataPaths.assets, noteId); await mkdir(noteAssetsDir, { recursive: true }); - // Write file const assetPath = join(noteAssetsDir, filename); await writeFile(assetPath, Buffer.from(bytes)); - return { - ok: true, - filename, - relPath: `${noteId}/${filename}`, - }; - } - ); - - // Activity stats (notes created/updated per week, last 52 weeks) - ipcMain.handle('notes:activityStats', async () => { - const allNotes = await repo.list({ archived: 'all', limit: 10000 }); - const now = Date.now(); - const fiftyTwoWeeksAgo = now - 52 * 7 * 24 * 60 * 60 * 1000; - - // Build a map of week -> { created, updated } - const weekMap = new Map(); - - for (const note of allNotes) { - const createdMs = new Date(note.metadata.createdAt).getTime(); - const updatedMs = new Date(note.metadata.updatedAt).getTime(); - - if (createdMs >= fiftyTwoWeeksAgo) { - const weekKey = getISOWeek(new Date(note.metadata.createdAt)); - const entry = weekMap.get(weekKey) ?? { created: 0, updated: 0 }; - entry.created++; - weekMap.set(weekKey, entry); - } + return { ok: true, filename, relPath: `${noteId}/${filename}` }; + }, + }); - if (updatedMs >= fiftyTwoWeeksAgo && updatedMs !== createdMs) { - const weekKey = getISOWeek(new Date(note.metadata.updatedAt)); - const entry = weekMap.get(weekKey) ?? { created: 0, updated: 0 }; - entry.updated++; - weekMap.set(weekKey, entry); + // ── Stats / counts ────────────────────────────────────────────────────── + + defineIpcHandler({ + channel: 'notes:activityStats', + args: z.tuple([]), + handler: async () => { + const allNotes = await repo.list({ archived: 'all', limit: 10000 }); + const now = Date.now(); + const fiftyTwoWeeksAgo = now - 52 * 7 * 24 * 60 * 60 * 1000; + + const weekMap = new Map(); + + for (const note of allNotes) { + const createdMs = new Date(note.metadata.createdAt).getTime(); + const updatedMs = new Date(note.metadata.updatedAt).getTime(); + + if (createdMs >= fiftyTwoWeeksAgo) { + const weekKey = getISOWeek(new Date(note.metadata.createdAt)); + const entry = weekMap.get(weekKey) ?? { created: 0, updated: 0 }; + entry.created++; + weekMap.set(weekKey, entry); + } + + if (updatedMs >= fiftyTwoWeeksAgo && updatedMs !== createdMs) { + const weekKey = getISOWeek(new Date(note.metadata.updatedAt)); + const entry = weekMap.get(weekKey) ?? { created: 0, updated: 0 }; + entry.updated++; + weekMap.set(weekKey, entry); + } } - } - - // Convert to sorted array - const weeks = Array.from(weekMap.entries()) - .map(([week, counts]) => ({ week, ...counts })) - .sort((a, b) => a.week.localeCompare(b.week)); - - // Calculate current streak (consecutive weeks with activity ending at current week) - const currentWeek = getISOWeek(new Date()); - let streak = 0; - let checkDate = new Date(); - for (let i = 0; i < 52; i++) { - const weekKey = getISOWeek(checkDate); - const entry = weekMap.get(weekKey); - if (entry && (entry.created > 0 || entry.updated > 0)) { - streak++; - } else if (i > 0) { - // Allow current week to have no activity yet - break; + + const weeks = Array.from(weekMap.entries()) + .map(([week, counts]) => ({ week, ...counts })) + .sort((a, b) => a.week.localeCompare(b.week)); + + const currentWeek = getISOWeek(new Date()); + let streak = 0; + let checkDate = new Date(); + for (let i = 0; i < 52; i++) { + const weekKey = getISOWeek(checkDate); + const entry = weekMap.get(weekKey); + if (entry && (entry.created > 0 || entry.updated > 0)) { + streak++; + } else if (i > 0) { + break; + } + checkDate = new Date(checkDate.getTime() - 7 * 24 * 60 * 60 * 1000); } - checkDate = new Date(checkDate.getTime() - 7 * 24 * 60 * 60 * 1000); - } - - return { - weeks, - totalNotes: allNotes.length, - currentStreak: streak, - currentWeek, - }; - }); - - // Count notes - ipcMain.handle('notes:count', async () => { - // Get all notes to compute counts - const allNotes = await repo.list({ archived: 'all' }); - - const counts = { - active: 0, - archived: 0, - total: allNotes.length, - pinned: 0, - deleted: 0, - byStatus: { + + return { + weeks, + totalNotes: allNotes.length, + currentStreak: streak, + currentWeek, + }; + }, + }); + + defineIpcHandler({ + channel: 'notes:count', + args: z.tuple([]), + handler: async () => { + const allNotes = await repo.list({ archived: 'all' }); + + const counts = { active: 0, - on_hold: 0, - completed: 0, - dropped: 0, - } as Record, - byNotebook: {} as Record, - }; - - for (const note of allNotes) { - // Count archived - if (note.metadata.archivedAt !== null) { - counts.archived++; - } else { - counts.active++; - } + archived: 0, + total: allNotes.length, + pinned: 0, + deleted: 0, + byStatus: { + active: 0, + on_hold: 0, + completed: 0, + dropped: 0, + } as Record, + byNotebook: {} as Record, + }; - // Count pinned - if (note.isPinned) { - counts.pinned++; - } + for (const note of allNotes) { + if (note.metadata.archivedAt !== null) counts.archived++; + else counts.active++; - // Count deleted (in trash) - if (note.isDeleted) { - counts.deleted++; - } + if (note.isPinned) counts.pinned++; + if (note.isDeleted) counts.deleted++; - // Count by status - if (note.status && counts.byStatus[note.status] !== undefined) { - counts.byStatus[note.status]++; - } + if (note.status && counts.byStatus[note.status] !== undefined) { + counts.byStatus[note.status]++; + } - // Count by notebook (active, non-deleted notes only) - if (note.notebookId && !note.isDeleted && !note.metadata.archivedAt) { - counts.byNotebook[note.notebookId] = (counts.byNotebook[note.notebookId] || 0) + 1; + if (note.notebookId && !note.isDeleted && !note.metadata.archivedAt) { + counts.byNotebook[note.notebookId] = (counts.byNotebook[note.notebookId] || 0) + 1; + } } - } - return counts; + return counts; + }, }); } @@ -489,7 +499,6 @@ export function registerNoteHandlers(deps: NoteHandlerDeps): void { function getISOWeek(date: Date): string { const d = new Date(date.getTime()); d.setHours(0, 0, 0, 0); - // Set to nearest Thursday (current date + 4 - current day number, with Sunday=7) d.setDate(d.getDate() + 4 - (d.getDay() || 7)); const yearStart = new Date(d.getFullYear(), 0, 1); const weekNum = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); diff --git a/apps/desktop/src/main/handlers/notebookHandlers.ts b/apps/desktop/src/main/handlers/notebookHandlers.ts index e72524d3..92835658 100644 --- a/apps/desktop/src/main/handlers/notebookHandlers.ts +++ b/apps/desktop/src/main/handlers/notebookHandlers.ts @@ -4,7 +4,7 @@ * Handles notebook CRUD, git settings per notebook, and reordering. */ -import { ipcMain } from 'electron'; +import { z } from 'zod'; import { createNotebookId, createNotebook, @@ -12,267 +12,271 @@ import { moveNotebook, INBOX_NOTEBOOK_ID, } from '@readied/core'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { SQLiteNotebookRepository } from './types.js'; export interface NotebookHandlerDeps { notebookRepository: SQLiteNotebookRepository; } +const IdSchema = z.string().min(1).max(128); +const NameSchema = z.string().min(1).max(256); + export function registerNotebookHandlers(deps: NotebookHandlerDeps): void { const { notebookRepository: repo } = deps; - // List all notebooks - ipcMain.handle('notebooks:list', async () => { - const notebooks = await repo.getAll(); - return notebooks.map(nb => ({ - id: nb.id, - name: nb.name, - parentId: nb.parentId, - depth: nb.depth, - order: nb.order, - createdAt: nb.createdAt, - updatedAt: nb.updatedAt, - })); + const serialize = (nb: { + id: string; + name: string; + parentId: string | null; + depth: number; + order: number; + createdAt: string; + updatedAt: string; + }) => ({ + id: nb.id, + name: nb.name, + parentId: nb.parentId, + depth: nb.depth, + order: nb.order, + createdAt: nb.createdAt, + updatedAt: nb.updatedAt, }); - // Get notebook tree - ipcMain.handle('notebooks:tree', async () => { - return repo.getTree(); + defineIpcHandler({ + channel: 'notebooks:list', + args: z.tuple([]), + handler: async () => { + const notebooks = await repo.getAll(); + return notebooks.map(serialize); + }, }); - // Get single notebook - ipcMain.handle('notebooks:get', async (_event, id: string) => { - const notebook = await repo.get(createNotebookId(id)); - if (!notebook) return null; - return { - id: notebook.id, - name: notebook.name, - parentId: notebook.parentId, - depth: notebook.depth, - order: notebook.order, - createdAt: notebook.createdAt, - updatedAt: notebook.updatedAt, - }; + defineIpcHandler({ + channel: 'notebooks:tree', + args: z.tuple([]), + handler: () => repo.getTree(), }); - // Get notebook with metadata - ipcMain.handle('notebooks:getWithMetadata', async (_event, id: string) => { - const notebook = await repo.getWithMetadata(createNotebookId(id)); - if (!notebook) return null; - return { - id: notebook.id, - name: notebook.name, - parentId: notebook.parentId, - depth: notebook.depth, - order: notebook.order, - createdAt: notebook.createdAt, - updatedAt: notebook.updatedAt, - noteCount: notebook.noteCount, - childCount: notebook.childCount, - }; + defineIpcHandler({ + channel: 'notebooks:get', + args: z.tuple([IdSchema]), + handler: async id => { + const notebook = await repo.get(createNotebookId(id)); + return notebook ? serialize(notebook) : null; + }, }); - // Create notebook - ipcMain.handle('notebooks:create', async (_event, input: { name: string; parentId?: string }) => { - let parentDepth = 0; - if (input.parentId) { - const parent = await repo.get(createNotebookId(input.parentId)); - if (parent) { - parentDepth = parent.depth; - } - } + defineIpcHandler({ + channel: 'notebooks:getWithMetadata', + args: z.tuple([IdSchema]), + handler: async id => { + const notebook = await repo.getWithMetadata(createNotebookId(id)); + if (!notebook) return null; + return { + ...serialize(notebook), + noteCount: notebook.noteCount, + childCount: notebook.childCount, + }; + }, + }); - const nextOrder = await repo.getNextOrder( - input.parentId ? createNotebookId(input.parentId) : null - ); + defineIpcHandler({ + channel: 'notebooks:create', + args: z.tuple([ + z.object({ + name: NameSchema, + parentId: IdSchema.optional(), + }), + ]), + handler: async input => { + let parentDepth = 0; + if (input.parentId) { + const parent = await repo.get(createNotebookId(input.parentId)); + if (parent) parentDepth = parent.depth; + } - const notebook = createNotebook({ - name: input.name, - parentId: input.parentId ? createNotebookId(input.parentId) : null, - parentDepth, - order: nextOrder, - }); + const nextOrder = await repo.getNextOrder( + input.parentId ? createNotebookId(input.parentId) : null + ); - await repo.save(notebook); + const notebook = createNotebook({ + name: input.name, + parentId: input.parentId ? createNotebookId(input.parentId) : null, + parentDepth, + order: nextOrder, + }); - return { - id: notebook.id, - name: notebook.name, - parentId: notebook.parentId, - depth: notebook.depth, - order: notebook.order, - createdAt: notebook.createdAt, - updatedAt: notebook.updatedAt, - }; + await repo.save(notebook); + return serialize(notebook); + }, }); - // Rename notebook - ipcMain.handle('notebooks:rename', async (_event, id: string, name: string) => { - const notebook = await repo.get(createNotebookId(id)); - if (!notebook) { - throw new Error('Notebook not found'); - } - - const updated = renameNotebook(notebook, name); - await repo.save(updated); - - return { - id: updated.id, - name: updated.name, - parentId: updated.parentId, - depth: updated.depth, - order: updated.order, - createdAt: updated.createdAt, - updatedAt: updated.updatedAt, - }; + defineIpcHandler({ + channel: 'notebooks:rename', + args: z.tuple([IdSchema, NameSchema]), + handler: async (id, name) => { + const notebook = await repo.get(createNotebookId(id)); + if (!notebook) { + throw new Error('Notebook not found'); + } + const updated = renameNotebook(notebook, name); + await repo.save(updated); + return serialize(updated); + }, }); - // Move notebook (recursively updates children's depth) - ipcMain.handle('notebooks:move', async (_event, id: string, newParentId: string | null) => { - const notebook = await repo.get(createNotebookId(id)); - if (!notebook) { - throw new Error('Notebook not found'); - } + defineIpcHandler({ + channel: 'notebooks:move', + args: z.tuple([IdSchema, IdSchema.nullable()]), + handler: async (id, newParentId) => { + const notebook = await repo.get(createNotebookId(id)); + if (!notebook) { + throw new Error('Notebook not found'); + } - // Prevent circular reference: can't move a notebook into its own descendant - if (newParentId) { - let current = await repo.get(createNotebookId(newParentId)); - while (current && current.parentId) { - if (current.parentId === notebook.id) { - throw new Error('CIRCULAR_REFERENCE'); + // Prevent circular reference: can't move a notebook into its own descendant + if (newParentId) { + let current = await repo.get(createNotebookId(newParentId)); + while (current && current.parentId) { + if (current.parentId === notebook.id) { + throw new Error('CIRCULAR_REFERENCE'); + } + current = await repo.get(current.parentId); } - current = await repo.get(current.parentId); } - } - let newParentDepth = 0; - if (newParentId) { - const parent = await repo.get(createNotebookId(newParentId)); - if (parent) { - newParentDepth = parent.depth; + let newParentDepth = 0; + if (newParentId) { + const parent = await repo.get(createNotebookId(newParentId)); + if (parent) newParentDepth = parent.depth; } - } - const result = moveNotebook( - notebook, - newParentId ? createNotebookId(newParentId) : null, - newParentDepth - ); + const result = moveNotebook( + notebook, + newParentId ? createNotebookId(newParentId) : null, + newParentDepth + ); - if (!result.success) { - throw new Error(result.reason); - } + if (!result.success) { + throw new Error(result.reason); + } - await repo.save(result.notebook); + await repo.save(result.notebook); - // Recursively update children's depth to match the new hierarchy - const updateChildrenDepth = async (parentId: string, parentDepth: number) => { - const children = await repo.getChildren(parentId as ReturnType); - for (const child of children) { - const newChildDepth = parentDepth + 1; - if (child.depth !== newChildDepth) { - await repo.save({ ...child, depth: newChildDepth }); - await updateChildrenDepth(child.id, newChildDepth); + const updateChildrenDepth = async (parentId: string, parentDepth: number) => { + const children = await repo.getChildren(parentId as ReturnType); + for (const child of children) { + const newChildDepth = parentDepth + 1; + if (child.depth !== newChildDepth) { + await repo.save({ ...child, depth: newChildDepth }); + await updateChildrenDepth(child.id, newChildDepth); + } } - } - }; - await updateChildrenDepth(result.notebook.id, result.notebook.depth); + }; + await updateChildrenDepth(result.notebook.id, result.notebook.depth); - return { - id: result.notebook.id, - name: result.notebook.name, - parentId: result.notebook.parentId, - depth: result.notebook.depth, - order: result.notebook.order, - createdAt: result.notebook.createdAt, - updatedAt: result.notebook.updatedAt, - }; + return serialize(result.notebook); + }, }); - // Delete notebook - ipcMain.handle('notebooks:delete', async (_event, id: string) => { - const notebookId = createNotebookId(id); - - if (notebookId === INBOX_NOTEBOOK_ID) { - throw new Error('Cannot delete Inbox notebook'); - } - - await repo.delete(notebookId); - return { success: true }; + defineIpcHandler({ + channel: 'notebooks:delete', + args: z.tuple([IdSchema]), + handler: async id => { + const notebookId = createNotebookId(id); + if (notebookId === INBOX_NOTEBOOK_ID) { + throw new Error('Cannot delete Inbox notebook'); + } + await repo.delete(notebookId); + return { success: true }; + }, }); - // Reorder notebooks within a parent - ipcMain.handle( - 'notebooks:reorder', - async (_event, parentId: string | null, orderedIds: string[]) => { + defineIpcHandler({ + channel: 'notebooks:reorder', + args: z.tuple([IdSchema.nullable(), z.array(IdSchema).max(10000)]), + handler: async (parentId, orderedIds) => { await repo.reorder( parentId ? createNotebookId(parentId) : null, orderedIds.map(id => createNotebookId(id)) ); return { success: true }; - } - ); + }, + }); // ═══════════════════════════════════════════════════════════════════════════ // Git Settings per Notebook // ═══════════════════════════════════════════════════════════════════════════ - // Enable git for a notebook - ipcMain.handle('notebooks:enableGit', async (_event, notebookId: string) => { - try { - repo.enableGit(createNotebookId(notebookId)); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to enable git', - }; - } + defineIpcHandler({ + channel: 'notebooks:enableGit', + args: z.tuple([IdSchema]), + handler: notebookId => { + try { + repo.enableGit(createNotebookId(notebookId)); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to enable git', + }; + } + }, }); - // Disable git for a notebook - ipcMain.handle('notebooks:disableGit', async (_event, notebookId: string) => { - try { - repo.disableGit(createNotebookId(notebookId)); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to disable git', - }; - } + defineIpcHandler({ + channel: 'notebooks:disableGit', + args: z.tuple([IdSchema]), + handler: notebookId => { + try { + repo.disableGit(createNotebookId(notebookId)); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to disable git', + }; + } + }, }); - // Check if git is enabled for a notebook - ipcMain.handle('notebooks:isGitEnabled', async (_event, notebookId: string) => { - try { - const enabled = repo.isGitEnabled(createNotebookId(notebookId)); - return { success: true, enabled }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to check git status', - }; - } + defineIpcHandler({ + channel: 'notebooks:isGitEnabled', + args: z.tuple([IdSchema]), + handler: notebookId => { + try { + const enabled = repo.isGitEnabled(createNotebookId(notebookId)); + return { success: true, enabled }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check git status', + }; + } + }, }); - // Get git settings for a notebook - ipcMain.handle('notebooks:getGitSettings', async (_event, notebookId: string) => { - try { - const settings = repo.getGitSettings(createNotebookId(notebookId)); - return { success: true, settings }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get git settings', - }; - } + defineIpcHandler({ + channel: 'notebooks:getGitSettings', + args: z.tuple([IdSchema]), + handler: notebookId => { + try { + const settings = repo.getGitSettings(createNotebookId(notebookId)); + return { success: true, settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get git settings', + }; + } + }, }); - // Toggle auto-commit for a notebook - ipcMain.handle( - 'notebooks:setGitAutoCommit', - async (_event, notebookId: string, enabled: boolean) => { + defineIpcHandler({ + channel: 'notebooks:setGitAutoCommit', + args: z.tuple([IdSchema, z.boolean()]), + handler: (notebookId, enabled) => { try { repo.setGitAutoCommit(createNotebookId(notebookId), enabled); return { success: true }; @@ -282,30 +286,22 @@ export function registerNotebookHandlers(deps: NotebookHandlerDeps): void { error: error instanceof Error ? error.message : 'Failed to set auto-commit', }; } - } - ); + }, + }); - // Get all git-enabled notebooks - ipcMain.handle('notebooks:getGitEnabled', async () => { - try { - const notebooks = repo.getGitEnabledNotebooks(); - return { - success: true, - notebooks: notebooks.map(nb => ({ - id: nb.id, - name: nb.name, - parentId: nb.parentId, - depth: nb.depth, - order: nb.order, - createdAt: nb.createdAt, - updatedAt: nb.updatedAt, - })), - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get git-enabled notebooks', - }; - } + defineIpcHandler({ + channel: 'notebooks:getGitEnabled', + args: z.tuple([]), + handler: () => { + try { + const notebooks = repo.getGitEnabledNotebooks(); + return { success: true, notebooks: notebooks.map(serialize) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get git-enabled notebooks', + }; + } + }, }); } diff --git a/apps/desktop/src/main/handlers/pluginHandlers.ts b/apps/desktop/src/main/handlers/pluginHandlers.ts index 5481b8b2..7f52e52b 100644 --- a/apps/desktop/src/main/handlers/pluginHandlers.ts +++ b/apps/desktop/src/main/handlers/pluginHandlers.ts @@ -8,16 +8,27 @@ import { join, normalize, basename } from 'path'; import { readFile, mkdir, rm, readdir, stat, rename } from 'fs/promises'; import { existsSync } from 'fs'; import { execFile } from 'child_process'; +import { writeFile } from 'fs/promises'; import { ipcMain, dialog, BrowserWindow, net } from 'electron'; -import type { DataPaths, Database } from './types.js'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import { scanPlugins } from '../pluginScanner.js'; -import { writeFile } from 'fs/promises'; +import type { DataPaths, Database } from './types.js'; export interface PluginHandlerDeps { dataPaths: DataPaths; db: Database; } +// Plugin IDs are constrained the same way we enforce on install (regex check +// on manifest.id). Mirror that here so the IPC boundary catches the same shape. +const PluginIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9_-]+$/); +const ConfigKeySchema = z.string().min(1).max(256); + export function registerPluginHandlers(deps: PluginHandlerDeps): void { const { dataPaths: paths, db: database } = deps; @@ -25,372 +36,410 @@ export function registerPluginHandlers(deps: PluginHandlerDeps): void { // Plugin Config Persistence // ═══════════════════════════════════════════════════════════════════════════ - ipcMain.handle('pluginConfig:get', (_event, pluginId: string, key: string) => { - const row = database - .prepare('SELECT value FROM plugin_config WHERE plugin_id = ? AND key = ?') - .get(pluginId, key) as { value: string } | undefined; - return row ? JSON.parse(row.value) : undefined; + defineIpcHandler({ + channel: 'pluginConfig:get', + args: z.tuple([PluginIdSchema, ConfigKeySchema]), + handler: (pluginId, key) => { + const row = database + .prepare('SELECT value FROM plugin_config WHERE plugin_id = ? AND key = ?') + .get(pluginId, key) as { value: string } | undefined; + return row ? JSON.parse(row.value) : undefined; + }, }); - ipcMain.handle('pluginConfig:set', (_event, pluginId: string, key: string, value: unknown) => { - database - .prepare('INSERT OR REPLACE INTO plugin_config (plugin_id, key, value) VALUES (?, ?, ?)') - .run(pluginId, key, JSON.stringify(value)); + defineIpcHandler({ + channel: 'pluginConfig:set', + args: z.tuple([PluginIdSchema, ConfigKeySchema, z.unknown()]), + handler: (pluginId, key, value) => { + database + .prepare('INSERT OR REPLACE INTO plugin_config (plugin_id, key, value) VALUES (?, ?, ?)') + .run(pluginId, key, JSON.stringify(value)); + }, }); - ipcMain.handle('pluginConfig:getAll', (_event, pluginId: string) => { - const rows = database - .prepare('SELECT key, value FROM plugin_config WHERE plugin_id = ?') - .all(pluginId) as Array<{ key: string; value: string }>; - const result: Record = {}; - for (const row of rows) { - result[row.key] = JSON.parse(row.value); - } - return result; + defineIpcHandler({ + channel: 'pluginConfig:getAll', + args: z.tuple([PluginIdSchema]), + handler: pluginId => { + const rows = database + .prepare('SELECT key, value FROM plugin_config WHERE plugin_id = ?') + .all(pluginId) as Array<{ key: string; value: string }>; + const result: Record = {}; + for (const row of rows) { + result[row.key] = JSON.parse(row.value); + } + return result; + }, }); - ipcMain.handle('pluginConfig:clear', (_event, pluginId: string) => { - database.prepare('DELETE FROM plugin_config WHERE plugin_id = ?').run(pluginId); + defineIpcHandler({ + channel: 'pluginConfig:clear', + args: z.tuple([PluginIdSchema]), + handler: pluginId => { + database.prepare('DELETE FROM plugin_config WHERE plugin_id = ?').run(pluginId); + }, }); // ═══════════════════════════════════════════════════════════════════════════ // Plugin Discovery // ═══════════════════════════════════════════════════════════════════════════ - // Scan filesystem for plugins - ipcMain.handle('plugins:scan', async () => { - return scanPlugins(paths.plugins); + defineIpcHandler({ + channel: 'plugins:scan', + args: z.tuple([]), + handler: () => scanPlugins(paths.plugins), }); - // Check if a plugin is enabled (default: true if no row exists) - ipcMain.handle('plugins:isEnabled', (_event, pluginId: string) => { - const row = database - .prepare('SELECT enabled FROM plugin_registry WHERE plugin_id = ?') - .get(pluginId) as { enabled: number } | undefined; - return row ? row.enabled === 1 : true; + defineIpcHandler({ + channel: 'plugins:isEnabled', + args: z.tuple([PluginIdSchema]), + handler: pluginId => { + const row = database + .prepare('SELECT enabled FROM plugin_registry WHERE plugin_id = ?') + .get(pluginId) as { enabled: number } | undefined; + return row ? row.enabled === 1 : true; + }, }); - // Set plugin enabled/disabled state - ipcMain.handle('plugins:setEnabled', (_event, pluginId: string, enabled: boolean) => { - database - .prepare( - 'INSERT INTO plugin_registry (plugin_id, enabled) VALUES (?, ?) ON CONFLICT(plugin_id) DO UPDATE SET enabled = ?' - ) - .run(pluginId, enabled ? 1 : 0, enabled ? 1 : 0); + defineIpcHandler({ + channel: 'plugins:setEnabled', + args: z.tuple([PluginIdSchema, z.boolean()]), + handler: (pluginId, enabled) => { + database + .prepare( + 'INSERT INTO plugin_registry (plugin_id, enabled) VALUES (?, ?) ON CONFLICT(plugin_id) DO UPDATE SET enabled = ?' + ) + .run(pluginId, enabled ? 1 : 0, enabled ? 1 : 0); + }, }); - // List all plugin registry state - ipcMain.handle('plugins:listState', () => { - const rows = database.prepare('SELECT plugin_id, enabled FROM plugin_registry').all() as Array<{ - plugin_id: string; - enabled: number; - }>; - return rows.map(row => ({ - pluginId: row.plugin_id, - enabled: row.enabled === 1, - })); + defineIpcHandler({ + channel: 'plugins:listState', + args: z.tuple([]), + handler: () => { + const rows = database + .prepare('SELECT plugin_id, enabled FROM plugin_registry') + .all() as Array<{ + plugin_id: string; + enabled: number; + }>; + return rows.map(row => ({ + pluginId: row.plugin_id, + enabled: row.enabled === 1, + })); + }, }); - // Read init.js user script (returns null if not found) - ipcMain.handle('plugins:readInitScript', async () => { - const initPath = join(paths.root, 'init.js'); - try { - const code = await readFile(initPath, 'utf-8'); - return code; - } catch { - return null; - } + defineIpcHandler({ + channel: 'plugins:readInitScript', + args: z.tuple([]), + handler: async () => { + const initPath = join(paths.root, 'init.js'); + try { + return await readFile(initPath, 'utf-8'); + } catch { + return null; + } + }, }); - // Install plugin from archive (.tar.gz or .zip) - ipcMain.handle('plugins:install', async () => { - const { filePaths, canceled } = await dialog.showOpenDialog({ - title: 'Install Plugin', - properties: ['openFile'], - filters: [{ name: 'Plugin Archive', extensions: ['tar.gz', 'tgz', 'zip'] }], - buttonLabel: 'Install', - }); - - if (canceled || !filePaths[0]) { - return { success: false, error: 'Cancelled' }; - } - - const archivePath = filePaths[0]; - const fileName = basename(archivePath).toLowerCase(); + defineIpcHandler({ + channel: 'plugins:install', + args: z.tuple([]), + handler: async () => { + const { filePaths, canceled } = await dialog.showOpenDialog({ + title: 'Install Plugin', + properties: ['openFile'], + filters: [{ name: 'Plugin Archive', extensions: ['tar.gz', 'tgz', 'zip'] }], + buttonLabel: 'Install', + }); - // Hoist tmpDir so it can be cleaned up in finally - let tmpDir: string | null = null; + if (canceled || !filePaths[0]) { + return { success: false, error: 'Cancelled' }; + } - try { - // Ensure plugins dir exists - await mkdir(paths.plugins, { recursive: true }); + const archivePath = filePaths[0]; + const fileName = basename(archivePath).toLowerCase(); - // Extract to a temp dir first, then move validated plugin folder - tmpDir = join(paths.plugins, `__installing_${Date.now()}`); - const extractDir = tmpDir; - await mkdir(extractDir, { recursive: true }); + // Hoist tmpDir so it can be cleaned up in finally + let tmpDir: string | null = null; - await new Promise((resolve, reject) => { - const cb = (error: Error | null) => { - if (error) reject(error); - else resolve(); - }; - if (fileName.endsWith('.zip')) { - if (process.platform === 'win32') { - execFile( - 'powershell', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - 'Expand-Archive', - '-Force', - '-Path', - archivePath, - '-DestinationPath', - extractDir, - ], - cb - ); + try { + // Ensure plugins dir exists + await mkdir(paths.plugins, { recursive: true }); + + // Extract to a temp dir first, then move validated plugin folder + tmpDir = join(paths.plugins, `__installing_${Date.now()}`); + const extractDir = tmpDir; + await mkdir(extractDir, { recursive: true }); + + await new Promise((resolve, reject) => { + const cb = (error: Error | null) => { + if (error) reject(error); + else resolve(); + }; + if (fileName.endsWith('.zip')) { + if (process.platform === 'win32') { + execFile( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Expand-Archive', + '-Force', + '-Path', + archivePath, + '-DestinationPath', + extractDir, + ], + cb + ); + } else { + execFile('unzip', ['-o', archivePath, '-d', extractDir], cb); + } } else { - execFile('unzip', ['-o', archivePath, '-d', extractDir], cb); + execFile('tar', ['-xzf', archivePath, '-C', extractDir], cb); + } + }); + + // Find the manifest.json — could be at root or one level deep + const entries = await readdir(extractDir); + let pluginSourceDir = extractDir; + + // If there's a single subdirectory, use that as the plugin root + if (entries.length === 1 && entries[0]) { + const candidatePath = join(extractDir, entries[0]); + const candidateStat = await stat(candidatePath); + if (candidateStat.isDirectory()) { + pluginSourceDir = candidatePath; } - } else { - execFile('tar', ['-xzf', archivePath, '-C', extractDir], cb); } - }); - // Find the manifest.json — could be at root or one level deep - const entries = await readdir(extractDir); - let pluginSourceDir = extractDir; - - // If there's a single subdirectory, use that as the plugin root - if (entries.length === 1 && entries[0]) { - const candidatePath = join(extractDir, entries[0]); - const candidateStat = await stat(candidatePath); - if (candidateStat.isDirectory()) { - pluginSourceDir = candidatePath; + // Validate: must have manifest.json + const manifestPath = join(pluginSourceDir, 'manifest.json'); + if (!existsSync(manifestPath)) { + return { success: false, error: 'No manifest.json found in archive' }; } - } - - // Validate: must have manifest.json - const manifestPath = join(pluginSourceDir, 'manifest.json'); - if (!existsSync(manifestPath)) { - return { success: false, error: 'No manifest.json found in archive' }; - } - const manifestRaw = await readFile(manifestPath, 'utf-8'); - const manifest = JSON.parse(manifestRaw); - if (!manifest.id || !manifest.name) { - return { success: false, error: 'Invalid manifest: missing id or name' }; - } + const manifestRaw = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw); + if (!manifest.id || !manifest.name) { + return { success: false, error: 'Invalid manifest: missing id or name' }; + } - // Validate plugin ID - only allow alphanumeric, hyphens, underscores - if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { - return { - success: false, - error: 'Invalid plugin ID: must be alphanumeric with hyphens/underscores only', - }; - } + // Validate plugin ID - only allow alphanumeric, hyphens, underscores + if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { + return { + success: false, + error: 'Invalid plugin ID: must be alphanumeric with hyphens/underscores only', + }; + } - // Verify path doesn't escape plugins directory - const destDir = join(paths.plugins, manifest.id); - if (!normalize(destDir).startsWith(normalize(paths.plugins))) { - return { success: false, error: 'Invalid plugin ID: path traversal detected' }; - } + // Verify path doesn't escape plugins directory + const destDir = join(paths.plugins, manifest.id); + if (!normalize(destDir).startsWith(normalize(paths.plugins))) { + return { success: false, error: 'Invalid plugin ID: path traversal detected' }; + } - // Move to final destination - if (existsSync(destDir)) { - await rm(destDir, { recursive: true, force: true }); - } + // Move to final destination + if (existsSync(destDir)) { + await rm(destDir, { recursive: true, force: true }); + } - await rename(pluginSourceDir, destDir); + await rename(pluginSourceDir, destDir); - return { success: true, pluginId: manifest.id, pluginName: manifest.name }; - } catch (error) { - return { success: false, error: String(error) }; - } finally { - if (tmpDir && existsSync(tmpDir)) { - await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + return { success: true, pluginId: manifest.id, pluginName: manifest.name }; + } catch (error) { + return { success: false, error: String(error) }; + } finally { + if (tmpDir && existsSync(tmpDir)) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } } - } + }, }); - // Install plugin from a remote URL (marketplace download) - ipcMain.handle('plugins:installFromUrl', async (_event, url: string, pluginSlug: string) => { - // Safety: only allow https URLs - if (!url.startsWith('https://')) { - return { success: false, error: 'Only HTTPS URLs are allowed' }; - } + defineIpcHandler({ + channel: 'plugins:installFromUrl', + args: z.tuple([z.string().url().startsWith('https://').max(2048), PluginIdSchema]), + handler: async (url, pluginSlug) => { + // Safety: only allow https URLs + if (!url.startsWith('https://')) { + return { success: false, error: 'Only HTTPS URLs are allowed' }; + } - // Ensure plugins dir exists - await mkdir(paths.plugins, { recursive: true }); + // Ensure plugins dir exists + await mkdir(paths.plugins, { recursive: true }); - // Download to a temp file inside the plugins dir - const tmpDir = join(paths.plugins, `__downloading_${Date.now()}`); - await mkdir(tmpDir, { recursive: true }); + // Download to a temp file inside the plugins dir + const tmpDir = join(paths.plugins, `__downloading_${Date.now()}`); + await mkdir(tmpDir, { recursive: true }); - try { - const response = await net.fetch(url); - if (!response.ok) { - return { success: false, error: `Download failed: HTTP ${response.status}` }; - } + try { + const response = await net.fetch(url); + if (!response.ok) { + return { success: false, error: `Download failed: HTTP ${response.status}` }; + } - // Limit download size to 50 MB - const MAX_PLUGIN_SIZE = 50 * 1024 * 1024; - const contentLength = response.headers.get('content-length'); - if (contentLength && parseInt(contentLength, 10) > MAX_PLUGIN_SIZE) { - return { success: false, error: 'Plugin archive exceeds maximum size of 50 MB' }; - } + // Limit download size to 50 MB + const MAX_PLUGIN_SIZE = 50 * 1024 * 1024; + const contentLength = response.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_PLUGIN_SIZE) { + return { success: false, error: 'Plugin archive exceeds maximum size of 50 MB' }; + } - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.byteLength > MAX_PLUGIN_SIZE) { - return { success: false, error: 'Plugin archive exceeds maximum size of 50 MB' }; - } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > MAX_PLUGIN_SIZE) { + return { success: false, error: 'Plugin archive exceeds maximum size of 50 MB' }; + } - // Determine archive type from URL pathname - const urlPathname = new URL(url).pathname.toLowerCase(); - const isZip = urlPathname.endsWith('.zip'); - const archiveExt = isZip ? '.zip' : '.tar.gz'; - const archivePath = join(tmpDir, `plugin${archiveExt}`); - await writeFile(archivePath, buffer); - - // Extract to a staging dir - const stageDir = join(tmpDir, 'extracted'); - await mkdir(stageDir, { recursive: true }); - - await new Promise((resolve, reject) => { - const cb = (error: Error | null) => { - if (error) reject(error); - else resolve(); - }; - if (isZip) { - if (process.platform === 'win32') { - execFile( - 'powershell', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - 'Expand-Archive', - '-Force', - '-Path', - archivePath, - '-DestinationPath', - stageDir, - ], - cb - ); + // Determine archive type from URL pathname + const urlPathname = new URL(url).pathname.toLowerCase(); + const isZip = urlPathname.endsWith('.zip'); + const archiveExt = isZip ? '.zip' : '.tar.gz'; + const archivePath = join(tmpDir, `plugin${archiveExt}`); + await writeFile(archivePath, buffer); + + // Extract to a staging dir + const stageDir = join(tmpDir, 'extracted'); + await mkdir(stageDir, { recursive: true }); + + await new Promise((resolve, reject) => { + const cb = (error: Error | null) => { + if (error) reject(error); + else resolve(); + }; + if (isZip) { + if (process.platform === 'win32') { + execFile( + 'powershell', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Expand-Archive', + '-Force', + '-Path', + archivePath, + '-DestinationPath', + stageDir, + ], + cb + ); + } else { + execFile('unzip', ['-o', archivePath, '-d', stageDir], cb); + } } else { - execFile('unzip', ['-o', archivePath, '-d', stageDir], cb); + execFile('tar', ['-xzf', archivePath, '-C', stageDir], cb); } - } else { - execFile('tar', ['-xzf', archivePath, '-C', stageDir], cb); - } - }); + }); - // Find manifest.json — could be at root or one level deep - const entries = await readdir(stageDir); - let pluginSourceDir = stageDir; + // Find manifest.json — could be at root or one level deep + const entries = await readdir(stageDir); + let pluginSourceDir = stageDir; - if (entries.length === 1 && entries[0]) { - const candidatePath = join(stageDir, entries[0]); - const candidateStat = await stat(candidatePath); - if (candidateStat.isDirectory()) { - pluginSourceDir = candidatePath; + if (entries.length === 1 && entries[0]) { + const candidatePath = join(stageDir, entries[0]); + const candidateStat = await stat(candidatePath); + if (candidateStat.isDirectory()) { + pluginSourceDir = candidatePath; + } } - } - // Validate: must have manifest.json - const manifestPath = join(pluginSourceDir, 'manifest.json'); - if (!existsSync(manifestPath)) { - return { success: false, error: 'No manifest.json found in downloaded archive' }; - } + // Validate: must have manifest.json + const manifestPath = join(pluginSourceDir, 'manifest.json'); + if (!existsSync(manifestPath)) { + return { success: false, error: 'No manifest.json found in downloaded archive' }; + } - const manifestRaw = await readFile(manifestPath, 'utf-8'); - const manifest = JSON.parse(manifestRaw); - if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) { - return { success: false, error: 'Invalid manifest: not a JSON object' }; - } - if (!manifest.id || !manifest.name) { - return { success: false, error: 'Invalid manifest: missing id or name' }; - } + const manifestRaw = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw); + if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) { + return { success: false, error: 'Invalid manifest: not a JSON object' }; + } + if (!manifest.id || !manifest.name) { + return { success: false, error: 'Invalid manifest: missing id or name' }; + } - // Cross-plugin overwrite protection: if we requested plugin A but the - // archive contains plugin B, block when it would overwrite an existing plugin - if (pluginSlug && manifest.id !== pluginSlug) { - const wouldOverwrite = join(paths.plugins, manifest.id); - if (existsSync(wouldOverwrite)) { + // Cross-plugin overwrite protection: if we requested plugin A but the + // archive contains plugin B, block when it would overwrite an existing plugin + if (pluginSlug && manifest.id !== pluginSlug) { + const wouldOverwrite = join(paths.plugins, manifest.id); + if (existsSync(wouldOverwrite)) { + return { + success: false, + error: `Archive contains "${manifest.id}" but "${pluginSlug}" was requested. Refusing to overwrite existing plugin.`, + }; + } + } + + // Validate plugin ID - only allow alphanumeric, hyphens, underscores + if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { return { success: false, - error: `Archive contains "${manifest.id}" but "${pluginSlug}" was requested. Refusing to overwrite existing plugin.`, + error: 'Invalid plugin ID: must be alphanumeric with hyphens/underscores only', }; } - } - // Validate plugin ID - only allow alphanumeric, hyphens, underscores - if (!/^[a-zA-Z0-9_-]+$/.test(manifest.id)) { + // Verify path doesn't escape plugins directory + const destDir = join(paths.plugins, manifest.id); + if (!normalize(destDir).startsWith(normalize(paths.plugins))) { + return { success: false, error: 'Invalid plugin ID: path traversal detected' }; + } + + // Move to final destination + if (existsSync(destDir)) { + await rm(destDir, { recursive: true, force: true }); + } + + await rename(pluginSourceDir, destDir); + return { - success: false, - error: 'Invalid plugin ID: must be alphanumeric with hyphens/underscores only', + success: true, + pluginId: manifest.id, + pluginName: manifest.name, + slugMismatch: pluginSlug && manifest.id !== pluginSlug ? pluginSlug : undefined, }; + } catch (error) { + return { success: false, error: String(error) }; + } finally { + if (existsSync(tmpDir)) { + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } } + }, + }); - // Verify path doesn't escape plugins directory - const destDir = join(paths.plugins, manifest.id); - if (!normalize(destDir).startsWith(normalize(paths.plugins))) { - return { success: false, error: 'Invalid plugin ID: path traversal detected' }; + defineIpcHandler({ + channel: 'plugins:uninstall', + args: z.tuple([PluginIdSchema]), + handler: async pluginId => { + // Safety: only allow removing from the plugins directory, prevent path traversal + const safeName = pluginId.replace(/[^a-z0-9-]/g, ''); + const pluginDir = join(paths.plugins, safeName); + const normalizedDir = normalize(pluginDir); + + if (!normalizedDir.startsWith(normalize(paths.plugins))) { + return { success: false, error: 'Invalid plugin ID' }; } - // Move to final destination - if (existsSync(destDir)) { - await rm(destDir, { recursive: true, force: true }); + if (!existsSync(pluginDir)) { + return { success: false, error: 'Plugin not found' }; } - await rename(pluginSourceDir, destDir); - - return { - success: true, - pluginId: manifest.id, - pluginName: manifest.name, - slugMismatch: pluginSlug && manifest.id !== pluginSlug ? pluginSlug : undefined, - }; - } catch (error) { - return { success: false, error: String(error) }; - } finally { - // Always clean up temp dir - if (existsSync(tmpDir)) { - await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + try { + await rm(pluginDir, { recursive: true, force: true }); + database.prepare('DELETE FROM plugin_registry WHERE plugin_id = ?').run(pluginId); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; } - } - }); - - // Uninstall plugin (remove its directory) - ipcMain.handle('plugins:uninstall', async (_event, pluginId: string) => { - // Safety: only allow removing from the plugins directory, prevent path traversal - const safeName = pluginId.replace(/[^a-z0-9-]/g, ''); - const pluginDir = join(paths.plugins, safeName); - const normalizedDir = normalize(pluginDir); - - if (!normalizedDir.startsWith(normalize(paths.plugins))) { - return { success: false, error: 'Invalid plugin ID' }; - } - - if (!existsSync(pluginDir)) { - return { success: false, error: 'Plugin not found' }; - } - - try { - await rm(pluginDir, { recursive: true, force: true }); - // Clean up registry entry - database.prepare('DELETE FROM plugin_registry WHERE plugin_id = ?').run(pluginId); - return { success: true }; - } catch (error) { - return { success: false, error: String(error) }; - } + }, }); - // Request plugin reload: broadcast to all windows except sender + // plugins:requestReload uses ipcMain.on (fire-and-forget, not invoke), + // so defineIpcHandler doesn't apply — left raw. ipcMain.on('plugins:requestReload', event => { const senderWebContents = event.sender; for (const win of BrowserWindow.getAllWindows()) { diff --git a/apps/desktop/src/main/handlers/shareHandlers.ts b/apps/desktop/src/main/handlers/shareHandlers.ts index c58f01b6..9f7d03aa 100644 --- a/apps/desktop/src/main/handlers/shareHandlers.ts +++ b/apps/desktop/src/main/handlers/shareHandlers.ts @@ -5,30 +5,44 @@ * Auto-copies the share URL to clipboard. */ -import { ipcMain, clipboard } from 'electron'; +import { clipboard } from 'electron'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import type { ApiClient } from '../services/apiClient.js'; export interface ShareHandlerDependencies { apiClient: ApiClient; } +// Note content can be quite long; cap at 1 MiB which is well above any +// realistic note and well below "this looks like an attack payload". +const SharePayloadSchema = z.object({ + noteId: z.string().min(1).max(128), + title: z.string().max(512), + content: z.string().max(1024 * 1024), + tags: z.array(z.string().max(64)).max(64).optional(), + backlinks: z + .array(z.object({ noteId: z.string().min(1).max(128), title: z.string().max(512) })) + .max(256) + .optional(), + wordCount: z.number().int().nonnegative().optional(), + notebookName: z.string().max(256).optional(), +}); + +const SlugSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9_-]+$/); + export function registerShareHandlers(deps: ShareHandlerDependencies): void { const { apiClient } = deps; - // Create or update a shared note - ipcMain.handle( - 'share:create', - async ( - _event, - input: { - noteId: string; - title: string; - content: string; - tags?: string[]; - backlinks?: Array<{ noteId: string; title: string }>; - wordCount?: number; - notebookName?: string; - } + defineIpcHandler({ + channel: 'share:create', + args: z.tuple([SharePayloadSchema]), + handler: async ( + input ): Promise<{ success: boolean; url?: string; slug?: string; error?: string }> => { try { const result = await apiClient.shareNote(input); @@ -40,13 +54,13 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void { error: error instanceof Error ? error.message : 'Failed to share note', }; } - } - ); + }, + }); - // Delete a shared note - ipcMain.handle( - 'share:delete', - async (_event, slug: string): Promise<{ success: boolean; error?: string }> => { + defineIpcHandler({ + channel: 'share:delete', + args: z.tuple([SlugSchema]), + handler: async (slug): Promise<{ success: boolean; error?: string }> => { try { await apiClient.unshareNote(slug); return { success: true }; @@ -56,6 +70,6 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void { error: error instanceof Error ? error.message : 'Failed to unshare note', }; } - } - ); + }, + }); } diff --git a/apps/desktop/src/main/handlers/updateHandlers.ts b/apps/desktop/src/main/handlers/updateHandlers.ts index 60981db8..6769f8ab 100644 --- a/apps/desktop/src/main/handlers/updateHandlers.ts +++ b/apps/desktop/src/main/handlers/updateHandlers.ts @@ -6,6 +6,8 @@ import { BrowserWindow, ipcMain } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { z } from 'zod'; +import { defineIpcHandler } from '../ipc/registry.js'; import { loggers } from '../logger'; import type { BroadcastFn } from './types.js'; @@ -14,11 +16,10 @@ export interface UpdateHandlerDeps { } export function registerUpdateHandlers(_deps: UpdateHandlerDeps): void { - // Manual check for updates - ipcMain.handle( - 'updates:checkNow', - async (): Promise<{ available: boolean; version?: string }> => { - // In development or without proper updater config, return mock response + defineIpcHandler({ + channel: 'updates:checkNow', + args: z.tuple([]), + handler: async (): Promise<{ available: boolean; version?: string }> => { if (process.env.NODE_ENV === 'development') { return { available: false }; } @@ -29,17 +30,14 @@ export function registerUpdateHandlers(_deps: UpdateHandlerDeps): void { cleanup(); resolve({ available: true, version: info.version }); }; - const onNotAvailable = () => { cleanup(); resolve({ available: false }); }; - const onError = () => { cleanup(); resolve({ available: false }); }; - const cleanup = () => { autoUpdater.removeListener('update-available', onAvailable); autoUpdater.removeListener('update-not-available', onNotAvailable); @@ -55,23 +53,30 @@ export function registerUpdateHandlers(_deps: UpdateHandlerDeps): void { resolve({ available: false }); }); }); - } - ); - - ipcMain.handle('updates:startDownload', async () => { - if (process.env.NODE_ENV === 'development') return { ok: false }; - try { - await autoUpdater.downloadUpdate(); - return { ok: true }; - } catch (err) { - const message = (err as Error).message; - loggers.updater().error({ error: message }, 'Failed to download update'); - return { ok: false, error: message }; - } + }, + }); + + defineIpcHandler({ + channel: 'updates:startDownload', + args: z.tuple([]), + handler: async () => { + if (process.env.NODE_ENV === 'development') return { ok: false }; + try { + await autoUpdater.downloadUpdate(); + return { ok: true }; + } catch (err) { + const message = (err as Error).message; + loggers.updater().error({ error: message }, 'Failed to download update'); + return { ok: false, error: message }; + } + }, }); + // installNow doesn't return a value AND triggers a quit — keeping the + // raw ipcMain.handle is simpler here since registry.ts always wraps in + // Promise and we don't want async semantics interfering with + // the synchronous window-destruction path. ipcMain.handle('updates:installNow', () => { - // Force-close all windows so macOS doesn't block the quit BrowserWindow.getAllWindows().forEach(win => { if (!win.isDestroyed()) win.destroy(); }); @@ -83,7 +88,6 @@ export function registerUpdateHandlers(_deps: UpdateHandlerDeps): void { export function initAutoUpdater(deps: UpdateHandlerDeps): void { const updateLog = loggers.updater(); - // Only check for updates in production if (process.env.NODE_ENV === 'development') { updateLog.debug('Skipping auto-updater in development'); return; @@ -125,7 +129,6 @@ export function initAutoUpdater(deps: UpdateHandlerDeps): void { deps.broadcastToWindows('updates:error', { message: err.message }); }); - // Check for updates after a short delay setTimeout(() => { void autoUpdater.checkForUpdates(); }, 3000); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4c9baceb..4f5d88e5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -11,8 +11,7 @@ import { initSentry } from './sentry'; initSentry(); import { join, normalize } from 'path'; -import { readFile, writeFile, unlink } from 'fs/promises'; -import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { existsSync } from 'fs'; import { app, BrowserWindow, @@ -31,16 +30,11 @@ import { SQLiteNotebookRepository, } from '@readied/storage-sqlite'; import { createNoteId, createNoteOperation, type NoteStatus } from '@readied/core'; -// eslint-disable-next-line @typescript-eslint/no-deprecated -import type { - LicenseStorage, - StoredTrialData, - StoredLicenseData, - StoredSubscriptionData, -} from '@readied/licensing'; import { initLogger, getLogger, loggers } from './logger'; import { TokenStorage } from './services/tokenStorage.js'; import { AiKeyStorage } from './services/aiKeyStorage.js'; +import { FileLicenseStorage } from './services/fileLicenseStorage.js'; +import { loadWindowState, saveWindowState } from './services/windowState.js'; import { getOrCreateDeviceInfo, type DeviceInfo } from './services/deviceInfo.js'; import { ApiClient } from './services/apiClient.js'; import { EncryptionService } from './services/encryptionService.js'; @@ -139,119 +133,10 @@ export function noteToSnapshot(note: { }; } -// ============================================================================ -// File-based License Storage -// ============================================================================ - -class FileLicenseStorage implements LicenseStorage { - private licensePath: string; - private trialPath: string; - private subscriptionPath: string; - - constructor(dataDir: string) { - this.licensePath = join(dataDir, 'license.json'); - this.trialPath = join(dataDir, 'trial.json'); - this.subscriptionPath = join(dataDir, 'subscription.json'); - } - - async readLicenseData(): Promise { - try { - if (!existsSync(this.licensePath)) { - return null; - } - const content = await readFile(this.licensePath, 'utf-8'); - return JSON.parse(content) as StoredLicenseData; - } catch { - return null; - } - } - - async writeLicenseData(data: StoredLicenseData): Promise { - await writeFile(this.licensePath, JSON.stringify(data, null, 2), 'utf-8'); - } - - async removeLicenseData(): Promise { - if (existsSync(this.licensePath)) { - await unlink(this.licensePath); - } - } - - async readTrialData(): Promise { - try { - if (!existsSync(this.trialPath)) { - return null; - } - const content = await readFile(this.trialPath, 'utf-8'); - return JSON.parse(content) as StoredTrialData; - } catch { - return null; - } - } - - async writeTrialData(data: StoredTrialData): Promise { - await writeFile(this.trialPath, JSON.stringify(data, null, 2), 'utf-8'); - } - - async readSubscriptionData(): Promise { - try { - if (!existsSync(this.subscriptionPath)) { - return null; - } - const content = await readFile(this.subscriptionPath, 'utf-8'); - return JSON.parse(content) as StoredSubscriptionData; - } catch { - return null; - } - } - - async writeSubscriptionData(data: StoredSubscriptionData): Promise { - await writeFile(this.subscriptionPath, JSON.stringify(data, null, 2), 'utf-8'); - } - - async removeSubscriptionData(): Promise { - if (existsSync(this.subscriptionPath)) { - await unlink(this.subscriptionPath); - } - } -} - -// ============================================================================ -// Window State Persistence -// ============================================================================ - -interface WindowState { - x?: number; - y?: number; - width: number; - height: number; - isMaximized?: boolean; -} - -const DEFAULT_WINDOW_STATE: WindowState = { - width: 1200, - height: 800, -}; - -function getWindowStatePath(): string { - return join(app.getPath('userData'), 'window-state.json'); -} - -function loadWindowState(): WindowState { - try { - const data = readFileSync(getWindowStatePath(), 'utf-8'); - return { ...DEFAULT_WINDOW_STATE, ...JSON.parse(data) }; - } catch { - return DEFAULT_WINDOW_STATE; - } -} - -function saveWindowState(state: WindowState): void { - try { - writeFileSync(getWindowStatePath(), JSON.stringify(state, null, 2)); - } catch (err) { - console.error('Failed to save window state:', err); - } -} +// File-based license storage and window state persistence live in +// dedicated modules under ./services/. See: +// - services/fileLicenseStorage.ts +// - services/windowState.ts // ============================================================================ // Initialization diff --git a/apps/desktop/src/main/ipc/registry.ts b/apps/desktop/src/main/ipc/registry.ts new file mode 100644 index 00000000..20484995 --- /dev/null +++ b/apps/desktop/src/main/ipc/registry.ts @@ -0,0 +1,59 @@ +/** + * Typed IPC handler registry. + * + * Wraps `ipcMain.handle()` with Zod validation at the boundary. Renderer + * input is treated as untrusted: if the schema doesn't accept the args, + * the handler throws BEFORE the business logic runs, and the renderer + * sees a structured "invalid args" error instead of a downstream crash. + * + * Pattern: + * + * defineIpcHandler({ + * channel: 'ai:saveKey', + * args: z.tuple([z.string().min(1), z.string().min(1)]), + * handler: (provider, apiKey) => aiKeyStorage.saveKey(provider, apiKey), + * }); + * + * Notes: + * - `args` is a Zod tuple matching the positional renderer arguments. + * Use `z.tuple([])` for no-arg handlers. + * - The schema runs on every invocation. Keep it tight (length caps, + * enums) — schemas are the contract. + */ + +import { ipcMain } from 'electron'; +import { z } from 'zod'; + +export interface DefineIpcHandlerConfig< + Schema extends z.ZodTuple, + Return, +> { + /** IPC channel name (e.g. 'ai:saveKey'). Must be unique. */ + channel: string; + /** Zod tuple describing the positional args sent by the renderer. */ + args: Schema; + /** Business logic. Receives validated args, never raw input. */ + handler: (...args: z.infer) => Promise | Return; +} + +export class IpcValidationError extends Error { + readonly channel: string; + constructor(channel: string, message: string) { + super(`Invalid IPC args for "${channel}": ${message}`); + this.name = 'IpcValidationError'; + this.channel = channel; + } +} + +export function defineIpcHandler< + Schema extends z.ZodTuple, + Return, +>(config: DefineIpcHandlerConfig): void { + ipcMain.handle(config.channel, async (_event, ...rawArgs: unknown[]) => { + const parsed = config.args.safeParse(rawArgs); + if (!parsed.success) { + throw new IpcValidationError(config.channel, parsed.error.message); + } + return config.handler(...(parsed.data as z.infer)); + }); +} diff --git a/apps/desktop/src/main/services/aiKeyStorage.ts b/apps/desktop/src/main/services/aiKeyStorage.ts index 61663dbe..4d72da03 100644 --- a/apps/desktop/src/main/services/aiKeyStorage.ts +++ b/apps/desktop/src/main/services/aiKeyStorage.ts @@ -2,10 +2,23 @@ * AI Key Storage Service * * Securely stores AI provider API keys using Electron's safeStorage API. - * Keys are encrypted with OS-level security (Keychain on macOS, DPAPI on Windows, libsecret on Linux). + * Keys are encrypted with OS-level security (Keychain on macOS, DPAPI on + * Windows, libsecret on Linux). All provider keys live in a single + * encrypted file as a JSON map: * - * All provider keys are stored in a single encrypted file as a JSON map: - * { "anthropic": "sk-ant-...", "openai": "sk-..." } + * { "anthropic": "sk-ant-...", "openai": "sk-..." } + * + * Error handling philosophy: + * - ENOENT on read → no keys yet, return empty map. Safe. + * - "Encryption not available" on read OR write → throw a typed error; + * the caller decides whether to surface it. We do NOT delete the + * stored file in this case — safeStorage may simply be unavailable + * temporarily (locked keychain on macOS after sleep, libsecret not + * running, etc.). Deleting would cause silent data loss. + * - Decryption / JSON parse failure → throw `AiKeyDecryptionError`. + * The previous implementation auto-cleared the file on any decrypt + * error, which is a footgun: if the user's keychain is temporarily + * inaccessible, their keys would vanish. * * @module AiKeyStorage */ @@ -14,123 +27,123 @@ import { promises as fs } from 'fs'; import { join } from 'path'; import { safeStorage } from 'electron'; -// ============================================================================ -// Types -// ============================================================================ - -/** Map of provider name to API key */ type KeyMap = Record; -// ============================================================================ -// AiKeyStorage Class -// ============================================================================ +export class AiKeyEncryptionUnavailableError extends Error { + constructor() { + super( + 'Encryption is not available on this system. ' + + 'On Linux, ensure libsecret (gnome-keyring / kwallet) is running.' + ); + this.name = 'AiKeyEncryptionUnavailableError'; + } +} + +export class AiKeyDecryptionError extends Error { + readonly cause: unknown; + constructor(cause: unknown) { + super( + 'Failed to decrypt AI keys. The OS keychain may be locked or the ' + + 'encrypted file may be corrupt. The stored file was left in place.' + ); + this.name = 'AiKeyDecryptionError'; + this.cause = cause; + } +} export class AiKeyStorage { private readonly filePath: string; /** - * Creates a new AiKeyStorage instance - * @param dataDir - User data directory path (e.g., app.getPath('userData')) + * @param dataDir - User data directory path (e.g. `app.getPath('userData')`) */ constructor(dataDir: string) { this.filePath = join(dataDir, 'ai-keys.encrypted'); } - /** - * Saves an API key for a provider - * @param provider - Provider identifier (e.g., 'anthropic', 'openai') - * @param apiKey - The API key to store - */ async saveKey(provider: string, apiKey: string): Promise { const keys = await this.readKeys(); keys[provider] = apiKey; await this.writeKeys(keys); } - /** - * Retrieves an API key for a provider - * @param provider - Provider identifier - * @returns API key string or null if not found - */ async getKey(provider: string): Promise { const keys = await this.readKeys(); return keys[provider] ?? null; } - /** - * Removes an API key for a provider - * @param provider - Provider identifier - */ async removeKey(provider: string): Promise { const keys = await this.readKeys(); delete keys[provider]; - // If no keys remain, remove the file entirely + // If no keys remain, remove the file entirely. if (Object.keys(keys).length === 0) { - await this.clearAll(); + await this.unlinkFile(); return; } await this.writeKeys(keys); } - /** - * Checks if a key exists for a provider - * @param provider - Provider identifier - * @returns true if a key is stored for this provider - */ async hasKey(provider: string): Promise { const keys = await this.readKeys(); return provider in keys; } - /** - * Lists all providers that have stored keys - * @returns Array of provider identifiers - */ async listProviders(): Promise { const keys = await this.readKeys(); return Object.keys(keys); } - // ========================================================================== - // Private helpers - // ========================================================================== - /** - * Reads and decrypts the key map from disk - * @returns Parsed key map, or empty object if file doesn't exist + * Read and decrypt the key map. + * + * Returns `{}` if no file exists yet. Throws on every other failure mode + * so the caller can decide how to surface the problem instead of silently + * losing state. */ private async readKeys(): Promise { + let encrypted: Buffer; try { - const encrypted = await fs.readFile(this.filePath); - const plaintext = safeStorage.decryptString(encrypted); - const keys = JSON.parse(plaintext) as KeyMap; - - // Validate structure: must be a plain object with string values - if (typeof keys !== 'object' || keys === null || Array.isArray(keys)) { - throw new Error('Invalid key map structure'); - } - - return keys; + encrypted = await fs.readFile(this.filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - // File doesn't exist - no keys saved yet return {}; } - // Decryption or parsing failed - clear corrupted file - await this.clearAll(); - return {}; + throw error; + } + + if (!safeStorage.isEncryptionAvailable()) { + throw new AiKeyEncryptionUnavailableError(); + } + + let plaintext: string; + try { + plaintext = safeStorage.decryptString(encrypted); + } catch (cause) { + throw new AiKeyDecryptionError(cause); + } + + let keys: unknown; + try { + keys = JSON.parse(plaintext); + } catch (cause) { + throw new AiKeyDecryptionError(cause); } + + if (typeof keys !== 'object' || keys === null || Array.isArray(keys)) { + throw new AiKeyDecryptionError(new Error('Decrypted payload is not a JSON object')); + } + + // We trust the shape because we wrote it. The handler boundary + // (defineIpcHandler in aiKeyHandlers.ts) already validates keys + // before they're written, so the saved map only contains strings. + return keys as KeyMap; } - /** - * Encrypts and writes the key map to disk - * @param keys - The key map to persist - */ private async writeKeys(keys: KeyMap): Promise { if (!safeStorage.isEncryptionAvailable()) { - throw new Error('Encryption is not available on this system'); + throw new AiKeyEncryptionUnavailableError(); } const plaintext = JSON.stringify(keys); @@ -138,17 +151,13 @@ export class AiKeyStorage { await fs.writeFile(this.filePath, encrypted); } - /** - * Removes the encrypted file from disk - */ - private async clearAll(): Promise { + private async unlinkFile(): Promise { try { await fs.unlink(this.filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } - // File doesn't exist - already clear } } } diff --git a/apps/desktop/src/main/services/fileLicenseStorage.ts b/apps/desktop/src/main/services/fileLicenseStorage.ts new file mode 100644 index 00000000..1c29e70c --- /dev/null +++ b/apps/desktop/src/main/services/fileLicenseStorage.ts @@ -0,0 +1,138 @@ +/** + * File-backed implementation of @readied/licensing's LicenseStorage. + * + * Persists three small JSON files under the user's data directory: + * + * license.json — legacy LicenseFile (StoredLicenseData) + * trial.json — local trial start (StoredTrialData) + * subscription.json — cached subscription state (StoredSubscriptionData) + * + * Subscription verification (Ed25519): + * - If the persisted cache contains `signedEnvelope`, the read path + * verifies it via @readied/licensing's verifySubscriptionSignature + * before returning. An invalid envelope causes the cache to be + * refused (read returns null) so the next call falls through to a + * fresh fetch from the API. + * - If the persisted cache has NO `signedEnvelope`, we accept it and + * log a structured warning. This is the migration window: once the + * server reliably emits envelopes for N releases, we can flip to + * strict mode (refuse unsigned caches). + * - trial.json is unsigned by design (see packages/licensing/README.md). + */ + +import { readFile, writeFile, unlink } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + verifySubscriptionSignature, + type LicenseStorage, + type StoredLicenseData, + type StoredTrialData, + type StoredSubscriptionData, +} from '@readied/licensing'; +import { loggers } from '../logger'; + +/** + * Ed25519 public key used to verify SignedSubscriptionEnvelope payloads. + * + * Public-by-design: the client needs it to verify. The matching PRIVATE + * key MUST live ONLY on the licensing server (env var, never the repo). + * + * Rotation procedure when this key needs to change: + * 1. Generate a new keypair on a trusted machine + * (see packages/licensing/README.md > "Rolling the signing key") + * 2. Ship a desktop release with the new public key embedded HERE + * 3. Wait for the install base to update + * 4. Switch the server to sign with the new private key + * Clients on the old release will stop verifying envelopes signed + * with the new key, falling back to the "no-envelope" lenient log — + * no hard lockout, but they'll re-fetch on every cache miss. + */ +const SUBSCRIPTION_PUBLIC_KEY = 'd049019b2ff05ccfd3802e0619d5897e21431a6f946af724c13ed7ecca7ec01f'; + +export class FileLicenseStorage implements LicenseStorage { + private readonly licensePath: string; + private readonly trialPath: string; + private readonly subscriptionPath: string; + + constructor(dataDir: string) { + this.licensePath = join(dataDir, 'license.json'); + this.trialPath = join(dataDir, 'trial.json'); + this.subscriptionPath = join(dataDir, 'subscription.json'); + } + + async readLicenseData(): Promise { + return readJsonOrNull(this.licensePath); + } + + async writeLicenseData(data: StoredLicenseData): Promise { + await writeFile(this.licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } + + async removeLicenseData(): Promise { + if (existsSync(this.licensePath)) { + await unlink(this.licensePath); + } + } + + async readTrialData(): Promise { + return readJsonOrNull(this.trialPath); + } + + async writeTrialData(data: StoredTrialData): Promise { + await writeFile(this.trialPath, JSON.stringify(data, null, 2), 'utf-8'); + } + + async readSubscriptionData(): Promise { + const cached = await readJsonOrNull(this.subscriptionPath); + if (!cached) return null; + + if (!cached.signedEnvelope) { + // Migration window: no envelope on disk. Accept the cache, log so + // operators can see when the population is fully migrated. + loggers + .license() + .warn( + { hasSubscriptionId: Boolean(cached.subscription?.subscriptionId) }, + 'subscription cache has no signed envelope — running in lenient mode' + ); + return cached; + } + + const result = await verifySubscriptionSignature(cached.signedEnvelope, { + publicKey: SUBSCRIPTION_PUBLIC_KEY, + }); + if (!result.valid) { + loggers + .license() + .error( + { error: result.error }, + 'subscription cache envelope failed verification — refusing cache, will refetch' + ); + // Refuse the cache. The next caller will fetch from the API. + return null; + } + + return cached; + } + + async writeSubscriptionData(data: StoredSubscriptionData): Promise { + await writeFile(this.subscriptionPath, JSON.stringify(data, null, 2), 'utf-8'); + } + + async removeSubscriptionData(): Promise { + if (existsSync(this.subscriptionPath)) { + await unlink(this.subscriptionPath); + } + } +} + +async function readJsonOrNull(path: string): Promise { + try { + if (!existsSync(path)) return null; + const content = await readFile(path, 'utf-8'); + return JSON.parse(content) as T; + } catch { + return null; + } +} diff --git a/apps/desktop/src/main/services/windowState.ts b/apps/desktop/src/main/services/windowState.ts new file mode 100644 index 00000000..ee6a90f1 --- /dev/null +++ b/apps/desktop/src/main/services/windowState.ts @@ -0,0 +1,48 @@ +/** + * Window position/size persistence. + * + * Saved to `window-state.json` under Electron's `userData` directory so + * the desktop reopens the last window in the same place across launches. + * + * Sync file I/O is intentional — `loadWindowState` is called during + * window construction before the renderer mounts, and `saveWindowState` + * runs during window close where event handlers don't await. + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; + +export interface WindowState { + x?: number; + y?: number; + width: number; + height: number; + isMaximized?: boolean; +} + +export const DEFAULT_WINDOW_STATE: WindowState = { + width: 1200, + height: 800, +}; + +function getWindowStatePath(): string { + return join(app.getPath('userData'), 'window-state.json'); +} + +export function loadWindowState(): WindowState { + try { + const data = readFileSync(getWindowStatePath(), 'utf-8'); + return { ...DEFAULT_WINDOW_STATE, ...JSON.parse(data) }; + } catch { + return DEFAULT_WINDOW_STATE; + } +} + +export function saveWindowState(state: WindowState): void { + try { + writeFileSync(getWindowStatePath(), JSON.stringify(state, null, 2)); + } catch (err) { + console.error('Failed to save window state:', err); + } +} diff --git a/apps/desktop/src/renderer/analytics.ts b/apps/desktop/src/renderer/analytics.ts deleted file mode 100644 index 5e7dc712..00000000 --- a/apps/desktop/src/renderer/analytics.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Analytics Module - Offline-First Event Tracking - * - * Privacy-respecting analytics that works offline. - * Events are queued when offline and synced when online. - * - * Setup: - * 1. Create account at https://app.posthog.com (free tier: 1M events/mo) - * 2. Set VITE_POSTHOG_KEY in your environment - * 3. Or use your own endpoint with VITE_ANALYTICS_ENDPOINT - */ - -interface AnalyticsEvent { - name: string; - properties?: Record; - timestamp: number; -} - -// Configuration -const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY || ''; -const ANALYTICS_ENDPOINT = import.meta.env.VITE_ANALYTICS_ENDPOINT || ''; -const QUEUE_KEY = 'readied_analytics_queue'; -const MAX_QUEUE_SIZE = 100; - -// Event queue for offline support -let eventQueue: AnalyticsEvent[] = []; - -// Load queue from localStorage on init -function loadQueue(): void { - try { - const stored = localStorage.getItem(QUEUE_KEY); - if (stored) { - eventQueue = JSON.parse(stored); - } - } catch { - eventQueue = []; - } -} - -// Save queue to localStorage -function saveQueue(): void { - try { - // Trim queue if too large - if (eventQueue.length > MAX_QUEUE_SIZE) { - eventQueue = eventQueue.slice(-MAX_QUEUE_SIZE); - } - localStorage.setItem(QUEUE_KEY, JSON.stringify(eventQueue)); - } catch { - // Ignore storage errors - } -} - -// Check if analytics is enabled -function isEnabled(): boolean { - // Disabled if no key configured - if (!POSTHOG_KEY && !ANALYTICS_ENDPOINT) { - return false; - } - - // Respect user preference (could add opt-out UI) - const optOut = localStorage.getItem('readied_analytics_optout'); - return optOut !== 'true'; -} - -/** - * Track an event - */ -export function track(name: string, properties?: Record): void { - if (!isEnabled()) return; - - const event: AnalyticsEvent = { - name, - properties: { - ...properties, - app_version: window.readied?.app ? 'readied' : 'unknown', - }, - timestamp: Date.now(), - }; - - eventQueue.push(event); - saveQueue(); - - // Try to flush immediately if online - if (navigator.onLine) { - void flush(); - } -} - -/** - * Flush queued events to server - */ -async function flush(): Promise { - if (eventQueue.length === 0) return; - if (!navigator.onLine) return; - - const events = [...eventQueue]; - eventQueue = []; - saveQueue(); - - try { - if (POSTHOG_KEY) { - // PostHog batch API - await fetch('https://app.posthog.com/batch/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - api_key: POSTHOG_KEY, - batch: events.map(e => ({ - event: e.name, - properties: e.properties, - timestamp: new Date(e.timestamp).toISOString(), - })), - }), - }); - } else if (ANALYTICS_ENDPOINT) { - // Custom endpoint - await fetch(ANALYTICS_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ events }), - }); - } - } catch { - // Re-queue events on failure - eventQueue = [...events, ...eventQueue]; - saveQueue(); - } -} - -/** - * Opt out of analytics - */ -export function optOut(): void { - localStorage.setItem('readied_analytics_optout', 'true'); - eventQueue = []; - saveQueue(); -} - -/** - * Opt back in to analytics - */ -export function optIn(): void { - localStorage.removeItem('readied_analytics_optout'); -} - -/** - * Check if user has opted out - */ -export function hasOptedOut(): boolean { - return localStorage.getItem('readied_analytics_optout') === 'true'; -} - -// Initialize -loadQueue(); - -// Flush on online -window.addEventListener('online', flush); - -// Flush before unload -window.addEventListener('beforeunload', flush); - -// Periodic flush (every 30 seconds if online) -setInterval(() => { - if (navigator.onLine && eventQueue.length > 0) { - void flush(); - } -}, 30000); - -// ===== PREDEFINED EVENTS ===== - -export const Analytics = { - // App lifecycle - appLaunched: () => track('app_launched'), - appClosed: () => track('app_closed'), - - // Notes - noteCreated: () => track('note_created'), - noteDeleted: () => track('note_deleted'), - noteExported: (format: string) => track('note_exported', { format }), - - // Features - featureUsed: (feature: string) => track('feature_used', { feature }), - searchUsed: () => track('search_used'), - graphViewOpened: () => track('graph_view_opened'), - backupCreated: () => track('backup_created'), - - // Errors (also sent to Sentry) - errorOccurred: (error: string) => track('error_occurred', { error }), -}; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index 2e1a6822..f24c2a6a 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -16,13 +16,7 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro import { indentUnit } from '@codemirror/language'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; -import { - syntaxHighlighting, - HighlightStyle, - indentOnInput, - bracketMatching, -} from '@codemirror/language'; -import { tags } from '@lezer/highlight'; +import { syntaxHighlighting, indentOnInput, bracketMatching } from '@codemirror/language'; import { toggleBold, toggleItalic, @@ -51,6 +45,7 @@ import { htmlToGfmMarkdown } from '../utils/htmlToMarkdown'; import { useEditorBufferStore } from '../stores/editorBufferStore'; import { useSettingsStore, selectEditor } from '../stores/settings'; import { setEditorView } from '../hooks/useCommandRegistry'; +import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js'; // Compartments for dynamic settings const lineNumbersCompartment = new Compartment(); @@ -61,132 +56,8 @@ const tabSizeCompartment = new Compartment(); const scrollPastEndCompartment = new Compartment(); const spellCheckCompartment = new Compartment(); -/** Scroll past end padding - allows scrolling content to top of viewport */ -const SCROLL_PAST_END_PADDING = '50vh'; - -/** Create theme with configurable settings (uses CSS variables for colors) */ -function createEditorTheme(fontSize: number, fontFamily: string, lineHeight: number) { - return EditorView.theme({ - '&': { - backgroundColor: 'transparent', - color: 'var(--cm-text)', - fontSize: `${fontSize}px`, - height: '100%', - }, - '.cm-content': { - fontFamily: fontFamily || "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace", - padding: '12px', - lineHeight: String(lineHeight), - caretColor: 'var(--cm-cursor)', - }, - '.cm-cursor': { - borderLeftColor: 'var(--cm-cursor)', - borderLeftWidth: '2px', - }, - '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { - backgroundColor: 'var(--cm-selection)', - }, - '.cm-activeLine': { - backgroundColor: 'var(--cm-active-line)', - }, - '.cm-activeLineGutter': { - backgroundColor: 'var(--cm-active-line)', - }, - '.cm-gutters': { - backgroundColor: 'transparent', - borderRight: '1px solid var(--cm-gutter-border)', - color: 'var(--cm-gutter-text)', - }, - '.cm-lineNumbers .cm-gutterElement': { - padding: '0 12px 0 16px', - minWidth: '40px', - }, - '.cm-scroller': { - overflow: 'auto', - }, - '.cm-line': { - padding: '0 4px', - }, - '&.cm-focused .cm-matchingBracket': { - backgroundColor: 'var(--cm-bracket-match)', - outline: 'none', - }, - // Autocomplete tooltip - '.cm-tooltip-autocomplete': { - backgroundColor: 'var(--cm-tooltip-bg)', - backdropFilter: 'blur(12px)', - border: '1px solid var(--cm-tooltip-border)', - borderRadius: '8px', - boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', - overflow: 'hidden', - }, - '.cm-tooltip-autocomplete > ul': { - fontFamily: "'Inter', -apple-system, sans-serif", - fontSize: '13px', - maxHeight: '300px', - }, - '.cm-tooltip-autocomplete > ul > li': { - padding: '8px 12px', - color: 'var(--cm-tooltip-text)', - cursor: 'pointer', - }, - '.cm-tooltip-autocomplete > ul > li[aria-selected]': { - backgroundColor: 'var(--accent-muted)', - color: 'var(--accent)', - }, - '.cm-completionLabel': { - fontWeight: '500', - }, - }); -} - -/** Syntax highlighting for Markdown (uses CSS variables for theme-aware colors) */ -const markdownHighlighting = HighlightStyle.define([ - // Headings - { tag: tags.heading1, color: 'var(--cm-heading)', fontWeight: '700', fontSize: '1.5em' }, - { tag: tags.heading2, color: 'var(--cm-heading)', fontWeight: '600', fontSize: '1.3em' }, - { tag: tags.heading3, color: 'var(--cm-heading)', fontWeight: '600', fontSize: '1.15em' }, - { tag: tags.heading4, color: 'var(--cm-heading)', fontWeight: '600' }, - { tag: tags.heading5, color: 'var(--cm-heading)', fontWeight: '600' }, - { tag: tags.heading6, color: 'var(--cm-heading)', fontWeight: '600' }, - - // Emphasis - { tag: tags.emphasis, fontStyle: 'italic', color: 'var(--cm-emphasis)' }, - { tag: tags.strong, fontWeight: '700', color: 'var(--cm-strong)' }, - { tag: tags.strikethrough, textDecoration: 'line-through', color: 'var(--cm-strikethrough)' }, - - // Code - { - tag: tags.monospace, - fontFamily: "'JetBrains Mono', monospace", - backgroundColor: 'var(--cm-code-bg)', - padding: '2px 4px', - borderRadius: '3px', - }, - - // Links - { tag: tags.link, color: 'var(--cm-link)', textDecoration: 'underline' }, - { tag: tags.url, color: 'var(--cm-link)' }, - - // Lists - { tag: tags.list, color: 'var(--cm-list)' }, - - // Quotes - { - tag: tags.quote, - color: 'var(--cm-quote)', - fontStyle: 'italic', - borderLeft: '3px solid var(--cm-quote-border)', - paddingLeft: '12px', - }, - - // Meta (like --- for frontmatter) - { tag: tags.meta, color: 'var(--cm-meta)' }, - { tag: tags.comment, color: 'var(--cm-meta)' }, - - // Punctuation - { tag: tags.processingInstruction, color: 'var(--cm-meta)' }, -]); +// createEditorTheme, markdownHighlighting, and SCROLL_PAST_END_PADDING +// live in editorTheme.ts. interface MarkdownEditorProps { initialContent: string; @@ -360,6 +231,18 @@ export const MarkdownEditor = forwardRef { + console.error('[CodeMirror] plugin error:', err); + const sentry = ( + globalThis as unknown as { + Sentry?: { captureException: (e: unknown, ctx?: unknown) => void }; + } + ).Sentry; + sentry?.captureException(err, { tags: { source: 'codemirror' } }); + }), + // Configurable: Line numbers lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), diff --git a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx index cd0d0854..9a914db6 100644 --- a/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx +++ b/apps/desktop/src/renderer/components/auth/MagicLinkFlow.tsx @@ -6,7 +6,7 @@ import { useState, useCallback, useEffect, useRef, FormEvent } from 'react'; import { Mail, CheckCircle, AlertCircle, X, RefreshCw } from 'lucide-react'; -import { useAuthStore } from '../../stores/authStore'; +import { useAuthStore, selectIsAuthenticated, selectError } from '../../stores/authStore'; import styles from './MagicLinkFlow.module.css'; export interface MagicLinkFlowProps { @@ -17,7 +17,9 @@ export interface MagicLinkFlowProps { type Step = 'email' | 'sent' | 'verifying' | 'success' | 'error'; export function MagicLinkFlow({ onSuccess, onCancel }: MagicLinkFlowProps) { - const { requestMagicLink, isAuthenticated, error: authError } = useAuthStore(); + const requestMagicLink = useAuthStore(state => state.requestMagicLink); + const isAuthenticated = useAuthStore(selectIsAuthenticated); + const authError = useAuthStore(selectError); const [step, setStep] = useState('email'); const [email, setEmail] = useState(''); const [error, setError] = useState(null); diff --git a/apps/desktop/src/renderer/components/editorTheme.ts b/apps/desktop/src/renderer/components/editorTheme.ts new file mode 100644 index 00000000..5b249c1d --- /dev/null +++ b/apps/desktop/src/renderer/components/editorTheme.ts @@ -0,0 +1,139 @@ +/** + * CodeMirror theme + syntax highlighting for Readied's MarkdownEditor. + * + * Pure values extracted from MarkdownEditor.tsx so theme tweaks don't + * force a rebuild of the entire editor file. Colors come from CSS + * variables (defined in renderer/styles/) so light/dark switching works + * without rebuilding the EditorView. + */ + +import { EditorView } from '@codemirror/view'; +import { HighlightStyle } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; + +/** Padding under the document so the user can scroll the last line near the top. */ +export const SCROLL_PAST_END_PADDING = '50vh'; + +/** Build a CodeMirror theme bound to the user's font/size preferences. */ +export function createEditorTheme(fontSize: number, fontFamily: string, lineHeight: number) { + return EditorView.theme({ + '&': { + backgroundColor: 'transparent', + color: 'var(--cm-text)', + fontSize: `${fontSize}px`, + height: '100%', + }, + '.cm-content': { + fontFamily: fontFamily || "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace", + padding: '12px', + lineHeight: String(lineHeight), + caretColor: 'var(--cm-cursor)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--cm-cursor)', + borderLeftWidth: '2px', + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { + backgroundColor: 'var(--cm-selection)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--cm-active-line)', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--cm-active-line)', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + borderRight: '1px solid var(--cm-gutter-border)', + color: 'var(--cm-gutter-text)', + }, + '.cm-lineNumbers .cm-gutterElement': { + padding: '0 12px 0 16px', + minWidth: '40px', + }, + '.cm-scroller': { + overflow: 'auto', + }, + '.cm-line': { + padding: '0 4px', + }, + '&.cm-focused .cm-matchingBracket': { + backgroundColor: 'var(--cm-bracket-match)', + outline: 'none', + }, + // Autocomplete tooltip + '.cm-tooltip-autocomplete': { + backgroundColor: 'var(--cm-tooltip-bg)', + backdropFilter: 'blur(12px)', + border: '1px solid var(--cm-tooltip-border)', + borderRadius: '8px', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', + overflow: 'hidden', + }, + '.cm-tooltip-autocomplete > ul': { + fontFamily: "'Inter', -apple-system, sans-serif", + fontSize: '13px', + maxHeight: '300px', + }, + '.cm-tooltip-autocomplete > ul > li': { + padding: '8px 12px', + color: 'var(--cm-tooltip-text)', + cursor: 'pointer', + }, + '.cm-tooltip-autocomplete > ul > li[aria-selected]': { + backgroundColor: 'var(--accent-muted)', + color: 'var(--accent)', + }, + '.cm-completionLabel': { + fontWeight: '500', + }, + }); +} + +/** Syntax highlighting for Markdown — uses CSS variables so dark/light works. */ +export const markdownHighlighting = HighlightStyle.define([ + // Headings + { tag: tags.heading1, color: 'var(--cm-heading)', fontWeight: '700', fontSize: '1.5em' }, + { tag: tags.heading2, color: 'var(--cm-heading)', fontWeight: '600', fontSize: '1.3em' }, + { tag: tags.heading3, color: 'var(--cm-heading)', fontWeight: '600', fontSize: '1.15em' }, + { tag: tags.heading4, color: 'var(--cm-heading)', fontWeight: '600' }, + { tag: tags.heading5, color: 'var(--cm-heading)', fontWeight: '600' }, + { tag: tags.heading6, color: 'var(--cm-heading)', fontWeight: '600' }, + + // Emphasis + { tag: tags.emphasis, fontStyle: 'italic', color: 'var(--cm-emphasis)' }, + { tag: tags.strong, fontWeight: '700', color: 'var(--cm-strong)' }, + { tag: tags.strikethrough, textDecoration: 'line-through', color: 'var(--cm-strikethrough)' }, + + // Code + { + tag: tags.monospace, + fontFamily: "'JetBrains Mono', monospace", + backgroundColor: 'var(--cm-code-bg)', + padding: '2px 4px', + borderRadius: '3px', + }, + + // Links + { tag: tags.link, color: 'var(--cm-link)', textDecoration: 'underline' }, + { tag: tags.url, color: 'var(--cm-link)' }, + + // Lists + { tag: tags.list, color: 'var(--cm-list)' }, + + // Quotes + { + tag: tags.quote, + color: 'var(--cm-quote)', + fontStyle: 'italic', + borderLeft: '3px solid var(--cm-quote-border)', + paddingLeft: '12px', + }, + + // Meta (like --- for frontmatter) + { tag: tags.meta, color: 'var(--cm-meta)' }, + { tag: tags.comment, color: 'var(--cm-meta)' }, + + // Punctuation + { tag: tags.processingInstruction, color: 'var(--cm-meta)' }, +]); diff --git a/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx index 431cc1e0..d83e7ab9 100644 --- a/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx +++ b/apps/desktop/src/renderer/components/sync/EnableSyncModal.tsx @@ -11,9 +11,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Cloud, Mail, CheckCircle, X, RefreshCw, Sparkles } from 'lucide-react'; -import { useAuthStore } from '../../stores/authStore'; -import { useLicense } from '../../contexts/LicenseContext'; import { getProductConfig } from '@readied/product-config'; +import { useAuthStore, selectIsAuthenticated, selectError } from '../../stores/authStore'; +import { useLicense } from '../../contexts/LicenseContext'; import styles from './LoginModal.module.css'; interface EnableSyncModalProps { @@ -46,7 +46,9 @@ export function EnableSyncModal({ isOpen, onClose }: EnableSyncModalProps) { const [isResending, setIsResending] = useState(false); const timerRef = useRef | null>(null); - const { requestMagicLink, isAuthenticated, error: authError } = useAuthStore(); + const requestMagicLink = useAuthStore(state => state.requestMagicLink); + const isAuthenticated = useAuthStore(selectIsAuthenticated); + const authError = useAuthStore(selectError); const { state: licenseState, openSubscribe } = useLicense(); const config = useMemo(() => getProductConfig(), []); const proPricing = config.plans.pro.pricing!; diff --git a/apps/desktop/src/renderer/hooks/useTheme.ts b/apps/desktop/src/renderer/hooks/useTheme.ts deleted file mode 100644 index 60b4a3e0..00000000 --- a/apps/desktop/src/renderer/hooks/useTheme.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Theme Hook - * - * Applies theme and accent color to document based on settings. - * Supports: 'dark', 'light', 'system' + custom accentColor - */ - -import { useEffect } from 'react'; -import { useSettingsStore, selectAppearance } from '../stores/settings'; - -type Theme = 'dark' | 'light' | 'system'; - -/** - * Get the resolved theme (dark or light) based on preference - */ -function resolveTheme(preference: Theme): 'dark' | 'light' { - if (preference === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - return preference; -} - -/** - * Apply theme to document root - */ -function applyTheme(theme: 'dark' | 'light') { - document.documentElement.setAttribute('data-theme', theme); - - // Also update meta theme-color for native UI - const metaThemeColor = document.querySelector('meta[name="theme-color"]'); - const color = theme === 'dark' ? '#0a0b0d' : '#ffffff'; - if (metaThemeColor) { - metaThemeColor.setAttribute('content', color); - } -} - -/** - * Parse hex color to RGB components - */ -function hexToRgb(hex: string): { r: number; g: number; b: number } | null { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1]!, 16), - g: parseInt(result[2]!, 16), - b: parseInt(result[3]!, 16), - } - : null; -} - -/** - * Darken a hex color by a percentage - */ -function darkenHex(hex: string, percent: number): string { - const rgb = hexToRgb(hex); - if (!rgb) return hex; - const factor = 1 - percent / 100; - const r = Math.round(rgb.r * factor); - const g = Math.round(rgb.g * factor); - const b = Math.round(rgb.b * factor); - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; -} - -/** - * Apply accent color to CSS custom properties - */ -function applyAccentColor(hex: string, theme: 'dark' | 'light') { - const root = document.documentElement; - const rgb = hexToRgb(hex); - - if (!rgb) return; - - // Main accent color - root.style.setProperty('--accent', hex); - - // Muted version (for backgrounds) - const mutedOpacity = theme === 'dark' ? 0.15 : 0.12; - root.style.setProperty('--accent-muted', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${mutedOpacity})`); - - // Strong version (darker for buttons on hover) - root.style.setProperty('--accent-strong', darkenHex(hex, 15)); - - // Also update CodeMirror accent-related tokens - root.style.setProperty('--cm-heading', hex); - root.style.setProperty('--cm-cursor', hex); - root.style.setProperty('--cm-selection', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`); - root.style.setProperty('--cm-bracket-match', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)`); - root.style.setProperty('--cm-quote-border', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)`); -} - -/** - * Hook to manage theme and accent color based on settings - */ -export function useTheme() { - const appearance = useSettingsStore(selectAppearance); - const { theme: themePreference, accentColor } = appearance; - - // Apply theme - useEffect(() => { - const resolved = resolveTheme(themePreference); - applyTheme(resolved); - - // If system preference, listen for changes - if (themePreference === 'system') { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleChange = (e: MediaQueryListEvent) => { - applyTheme(e.matches ? 'dark' : 'light'); - }; - - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - } - }, [themePreference]); - - // Apply accent color - useEffect(() => { - const resolved = resolveTheme(themePreference); - applyAccentColor(accentColor, resolved); - }, [accentColor, themePreference]); - - return { - theme: themePreference, - resolvedTheme: resolveTheme(themePreference), - }; -} diff --git a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx index ae981aef..4bd3cfc5 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx @@ -17,8 +17,18 @@ import { ChevronRight, } from 'lucide-react'; import { getProductConfig } from '@readied/product-config'; -import { useAuthStore } from '../../../stores/authStore'; -import { useSyncStore } from '../../../stores/syncStore'; +import { + useAuthStore, + selectUser, + selectIsAuthenticated, + selectIsLoading, +} from '../../../stores/authStore'; +import { + useSyncStore, + selectStatus, + selectLastSyncAt, + selectConflicts, +} from '../../../stores/syncStore'; import { useLicense } from '../../../contexts/LicenseContext'; import { SettingGroup } from '../components/SettingGroup'; import { SettingRow } from '../components/SettingRow'; @@ -39,8 +49,15 @@ function formatBytes(bytes: number): string { } export function AccountSection() { - const { user, isAuthenticated, isLoading, logout, loadSession } = useAuthStore(); - const { syncNow, status: syncStatus, lastSyncAt, conflicts } = useSyncStore(); + const user = useAuthStore(selectUser); + const isAuthenticated = useAuthStore(selectIsAuthenticated); + const isLoading = useAuthStore(selectIsLoading); + const logout = useAuthStore(state => state.logout); + const loadSession = useAuthStore(state => state.loadSession); + const syncNow = useSyncStore(state => state.syncNow); + const syncStatus = useSyncStore(selectStatus); + const lastSyncAt = useSyncStore(selectLastSyncAt); + const conflicts = useSyncStore(selectConflicts); const { state: licenseState, openSubscribe } = useLicense(); const [showMagicLinkFlow, setShowMagicLinkFlow] = useState(false); const [message, setMessage] = useState(null); diff --git a/apps/desktop/src/renderer/plugins/tables.tsx b/apps/desktop/src/renderer/plugins/tables.tsx index 701d7d6d..054a8b54 100644 --- a/apps/desktop/src/renderer/plugins/tables.tsx +++ b/apps/desktop/src/renderer/plugins/tables.tsx @@ -1,13 +1,6 @@ import { useState, useCallback, useMemo, type ReactElement } from 'react'; -import { - ViewPlugin, - WidgetType, - Decoration, - type ViewUpdate, - type DecorationSet, - type EditorView, -} from '@codemirror/view'; -import { RangeSetBuilder } from '@codemirror/state'; +import { WidgetType, Decoration, EditorView, type DecorationSet } from '@codemirror/view'; +import { RangeSetBuilder, StateField, type EditorState } from '@codemirror/state'; import type { PluginManifest, ZoneComponentProps } from '@readied/plugin-api'; import React from 'react'; @@ -266,28 +259,23 @@ class TableWidget extends WidgetType { } } -function buildTableDecorations(view: EditorView): DecorationSet { +// Build table decorations from EditorState (StateField-compatible). +// We MUST use StateField, not ViewPlugin: tables span multiple lines, and +// CodeMirror forbids Decoration.replace() ranges that include line breaks +// when provided by a ViewPlugin. See dev.to/marijn — "Decorations that +// replace line breaks may not be specified via plugins". +function buildTableDecorations(state: EditorState): DecorationSet { const builder = new RangeSetBuilder(); - const doc = view.state.doc; + const doc = state.doc; const docText = doc.toString(); const ranges = findTableRanges(docText); - const sel = view.state.selection.main; + const sel = state.selection.main; for (const range of ranges) { // Skip if cursor is inside this table range (show raw markdown for editing) if (sel.from >= range.from && sel.from <= range.to) continue; if (sel.to >= range.from && sel.to <= range.to) continue; - // Only process tables in visible ranges - let visible = false; - for (const vr of view.visibleRanges) { - if (range.from <= vr.to && range.to >= vr.from) { - visible = true; - break; - } - } - if (!visible) continue; - const parsed = parseGfmTable(range.text, range.from); if (!parsed) continue; @@ -298,24 +286,18 @@ function buildTableDecorations(view: EditorView): DecorationSet { return builder.finish(); } -const tableViewPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - - constructor(view: EditorView) { - this.decorations = buildTableDecorations(view); - } - - update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet || update.viewportChanged) { - this.decorations = buildTableDecorations(update.view); - } +const tableDecorationsField = StateField.define({ + create(state) { + return buildTableDecorations(state); + }, + update(decorations, tr) { + if (tr.docChanged || tr.selection) { + return buildTableDecorations(tr.state); } + return decorations.map(tr.changes); }, - { - decorations: v => v.decorations, - } -); + provide: f => EditorView.decorations.from(f), +}); // ============================================================ // Feature 3: Sortable Preview Table (React component) @@ -493,7 +475,7 @@ export const tablesPlugin: PluginManifest = { // --- Feature 2: WYSIWYG toggle --- const enableWysiwyg = () => { if (unregisterWysiwyg) return; - unregisterWysiwyg = context.registerExtensions('table-wysiwyg', [tableViewPlugin]); + unregisterWysiwyg = context.registerExtensions('table-wysiwyg', [tableDecorationsField]); context.log.info('Table WYSIWYG enabled'); }; diff --git a/apps/desktop/src/renderer/settings.tsx b/apps/desktop/src/renderer/settings.tsx deleted file mode 100644 index 98328021..00000000 --- a/apps/desktop/src/renderer/settings.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { SettingsApp } from './pages/settings/SettingsApp'; -import { LicenseProvider } from './contexts/LicenseContext'; -import './styles/global.css'; - -class SettingsErrorBoundary extends React.Component< - { children: React.ReactNode }, - { error: Error | null } -> { - state: { error: Error | null } = { error: null }; - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - render() { - if (this.state.error) { - return ( -
-

Settings failed to load

-
-            {this.state.error.message}
-          
-
-            {this.state.error.stack}
-          
-
- ); - } - return this.props.children; - } -} - -// Create QueryClient for TanStack Query -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60, // 1 minute - retry: 1, - }, - }, -}); - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - -); diff --git a/apps/desktop/src/renderer/ui/patterns/.gitkeep b/apps/desktop/src/renderer/ui/patterns/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/desktop/src/renderer/ui/patterns/Modal.module.css b/apps/desktop/src/renderer/ui/patterns/Modal.module.css deleted file mode 100644 index 2b0a855e..00000000 --- a/apps/desktop/src/renderer/ui/patterns/Modal.module.css +++ /dev/null @@ -1,122 +0,0 @@ -/* ============================================================================= - Modal Pattern - Glass-effect modal with overlay, scale animation, and portal rendering. - ============================================================================= */ - -/* ── Overlay ─────────────────────────────────────────────────────────────── */ - -.overlay { - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - animation: fade-in var(--transition-normal) ease both; -} - -/* ── Content ─────────────────────────────────────────────────────────────── */ - -.content { - position: relative; - width: 100%; - max-height: calc(100vh - 80px); - overflow-y: auto; - background: var(--glass-bg); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); - border: 1px solid var(--glass-border); - border-radius: var(--radius-xl); - box-shadow: var(--glass-shadow); - animation: scale-in var(--transition-normal) ease both; -} - -/* ── Sizes ───────────────────────────────────────────────────────────────── */ - -.sm { - max-width: 360px; -} - -.md { - max-width: 480px; -} - -.lg { - max-width: 640px; -} - -/* ── Header ──────────────────────────────────────────────────────────────── */ - -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-4) var(--space-5); - border-bottom: 1px solid var(--border-subtle); -} - -.title { - margin: 0; - font-family: var(--font-sans); - font-size: var(--text-lg); - font-weight: var(--font-weight-semibold); - line-height: var(--leading-tight); - letter-spacing: var(--tracking-tight); - color: var(--text-primary); -} - -.closeButton { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - margin: 0; - background: transparent; - border: none; - border-radius: var(--radius-md); - color: var(--text-muted); - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); -} - -.closeButton:hover { - background: var(--bg-hover); - color: var(--text-primary); -} - -.closeButton:active { - background: var(--bg-active); -} - -/* ── Body ────────────────────────────────────────────────────────────────── */ - -.body { - padding: var(--space-5); -} - -/* ── Animations ──────────────────────────────────────────────────────────── */ - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes scale-in { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } -} diff --git a/apps/desktop/src/renderer/ui/patterns/Modal.tsx b/apps/desktop/src/renderer/ui/patterns/Modal.tsx deleted file mode 100644 index 6f587cf8..00000000 --- a/apps/desktop/src/renderer/ui/patterns/Modal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback, useEffect, useId, useRef, type ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import styles from './Modal.module.css'; - -export interface ModalProps { - open: boolean; - onClose: () => void; - title?: string; - children: ReactNode; - size?: 'sm' | 'md' | 'lg'; - closeOnOverlay?: boolean; - closeOnEscape?: boolean; -} - -export function Modal({ - open, - onClose, - title, - children, - size = 'md', - closeOnOverlay = true, - closeOnEscape = true, -}: ModalProps) { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (closeOnEscape && e.key === 'Escape') { - onClose(); - } - }, - [closeOnEscape, onClose] - ); - - useEffect(() => { - if (!open) return; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [open, handleKeyDown]); - - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (closeOnOverlay && e.target === e.currentTarget) { - onClose(); - } - }, - [closeOnOverlay, onClose] - ); - - const contentRef = useRef(null); - const generatedId = useId(); - - // Focus the modal container on open - useEffect(() => { - if (open && contentRef.current) { - contentRef.current.focus(); - } - }, [open]); - - if (!open) return null; - - const titleId = title != null ? generatedId : undefined; - - return createPortal( -
-
- {title != null && ( -
-

- {title} -

- -
- )} -
{children}
-
-
, - document.body - ); -} diff --git a/apps/desktop/src/renderer/ui/patterns/index.ts b/apps/desktop/src/renderer/ui/patterns/index.ts deleted file mode 100644 index 05844a90..00000000 --- a/apps/desktop/src/renderer/ui/patterns/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Modal } from './Modal'; -export type { ModalProps } from './Modal'; diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index d74d4049..d83fca40 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['src/**/__tests__/**/*.test.ts'], + coverage: sharedCoverage, }, }); diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..24b39654 --- /dev/null +++ b/knip.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "workspaces": { + ".": { + "entry": ["eslint.config.js"], + "ignoreDependencies": [ + "@semantic-release/changelog", + "@semantic-release/commit-analyzer", + "@semantic-release/exec", + "@semantic-release/git", + "@semantic-release/github", + "@semantic-release/release-notes-generator", + "conventional-changelog-conventionalcommits", + "semantic-release" + ] + }, + "apps/desktop": { + "entry": [ + "src/main/index.ts", + "src/preload/index.ts", + "src/renderer/main.tsx", + "electron-vite.config.ts", + "vitest.config.ts", + "playwright.config.ts", + "e2e/**/*.{ts,spec.ts}" + ] + }, + "apps/web": { + "entry": ["src/**/*.{ts,tsx,astro}", "astro.config.{ts,js,mjs}"] + }, + "packages/*": { + "entry": ["src/index.ts"] + } + } +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..66845786 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,20 @@ +# Lefthook config — replaces husky. +# Install: `pnpm install` triggers postinstall → `lefthook install`. +# Skip a single run with LEFTHOOK=0 git commit ... + +pre-commit: + parallel: true + commands: + lint-staged: + run: pnpm lint-staged + stage_fixed: true + +pre-push: + commands: + typecheck: + run: pnpm -r typecheck + +commit-msg: + commands: + commitlint: + run: pnpm commitlint --edit {1} diff --git a/package.json b/package.json index 7e215c69..1c4d7b5a 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,15 @@ "dev": "turbo dev", "build": "turbo build", "test": "turbo test --filter=!@readied/storage-sqlite", + "test:coverage": "turbo test --filter=!@readied/storage-sqlite -- --coverage", "lint": "eslint packages apps --cache", "lint:fix": "eslint packages apps --fix --cache", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\" --ignore-path .gitignore", "format:check": "prettier --check \"**/*.{ts,tsx,js,json,md}\" --ignore-path .gitignore", "typecheck": "turbo typecheck", "clean": "turbo clean && rm -rf node_modules .eslintcache", - "prepare": "husky" + "knip": "knip", + "postinstall": "lefthook install" }, "devDependencies": { "@commitlint/cli": "^21.0.2", @@ -49,10 +51,12 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.8", "@semantic-release/release-notes-generator": "^14.1.1", + "@vitest/coverage-v8": "^4.1.8", "conventional-changelog-conventionalcommits": "^9.3.1", "eslint": "^10.4.1", "eslint-plugin-import-x": "^4.16.2", - "husky": "^9.1.7", + "knip": "^5.66.0", + "lefthook": "^1.13.6", "lint-staged": "^17.0.7", "prettier": "^3.8.3", "semantic-release": "^25.0.3", @@ -62,7 +66,11 @@ "vitest": "^4.1.8" }, "lint-staged": { - "*.{ts,tsx,js,json,md}": "prettier --write" + "*.{ts,tsx,js}": [ + "eslint --cache --fix --max-warnings 0", + "prettier --write" + ], + "*.{json,md}": "prettier --write" }, "packageManager": "pnpm@9.15.1", "pnpm": {}, diff --git a/packages/api/.dev.vars b/packages/api/.dev.vars index 08f21ae5..1e97894f 100644 --- a/packages/api/.dev.vars +++ b/packages/api/.dev.vars @@ -17,5 +17,12 @@ RESEND_API_KEY="re_your_key_here" # Stripe Webhook Secret (get from: https://dashboard.stripe.com/webhooks) STRIPE_WEBHOOK_SECRET="whsec_your_secret_here" +# Ed25519 private key used to sign SignedSubscriptionEnvelope payloads. +# Generate locally with: +# cd packages/licensing && node -e "import('@noble/ed25519').then(async (ed) => { const sk = ed.utils.randomSecretKey ? ed.utils.randomSecretKey() : ed.utils.randomPrivateKey(); const pk = await ed.getPublicKeyAsync(sk); const hex = (b) => Array.from(b).map(x => x.toString(16).padStart(2,'0')).join(''); console.log('PUBLIC:', hex(pk), '\nPRIVATE:', hex(sk)); });" +# Put the PUBLIC in apps/desktop/src/main/services/fileLicenseStorage.ts > SUBSCRIPTION_PUBLIC_KEY +# Put the PRIVATE here (and as the Cloudflare secret in production/staging) +LICENSE_SIGNING_PRIVATE_KEY="hex-encoded-32-byte-ed25519-private-key" + # Environment ENVIRONMENT="development" diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index 7382f40e..10995c53 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, + coverage: sharedCoverage, }, }); diff --git a/packages/api/wrangler.toml b/packages/api/wrangler.toml index 19deaab9..c93ac1f4 100644 --- a/packages/api/wrangler.toml +++ b/packages/api/wrangler.toml @@ -27,3 +27,8 @@ vars = { ENVIRONMENT = "production" } # JWT_SECRET - Secret for signing JWTs (generate with: openssl rand -base64 32) # RESEND_API_KEY - API key for Resend email service # STRIPE_WEBHOOK_SECRET - Stripe webhook signing secret +# LICENSE_SIGNING_PRIVATE_KEY - Ed25519 private key (hex) used to sign +# SignedSubscriptionEnvelope payloads. The matching public key is +# embedded in apps/desktop/src/main/services/fileLicenseStorage.ts +# (SUBSCRIPTION_PUBLIC_KEY). Rotation requires shipping a new desktop +# release with the new public key FIRST — see that file's comment. diff --git a/packages/commands/src/markdown/commands.test.ts b/packages/commands/src/markdown/commands.test.ts new file mode 100644 index 00000000..808202bc --- /dev/null +++ b/packages/commands/src/markdown/commands.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EditorState, EditorSelection } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { + toggleBold, + toggleItalic, + toggleStrikethrough, + toggleInlineCode, + insertHeading, + insertUnorderedList, + insertOrderedList, + insertCheckbox, + insertQuote, + insertCodeBlock, + insertHorizontalRule, +} from './commands.js'; + +// CodeMirror commands need an EditorView to call view.dispatch + view.focus. +// We fake one with just the surface area the command functions touch — no DOM. +function fakeView(initialDoc: string, selection: { from: number; to: number }) { + let state = EditorState.create({ + doc: initialDoc, + selection: EditorSelection.range(selection.from, selection.to), + }); + const focus = vi.fn(); + const dispatch = vi.fn((spec: Parameters[0]) => { + state = state.update(spec).state; + }); + const view = { + get state() { + return state; + }, + dispatch, + focus, + }; + return { + view: view as unknown as EditorView, + doc: () => state.doc.toString(), + selectionMain: () => state.selection.main, + dispatchCalls: () => dispatch.mock.calls.length, + }; +} + +describe('@readied/commands markdown', () => { + describe('wrapping commands', () => { + it('toggleBold wraps selected text with **', () => { + const t = fakeView('hello world', { from: 0, to: 5 }); + toggleBold(t.view); + expect(t.doc()).toBe('**hello** world'); + }); + + it('toggleBold unwraps when applied to already-bold text', () => { + const t = fakeView('**hello** world', { from: 2, to: 7 }); + toggleBold(t.view); + expect(t.doc()).toBe('hello world'); + }); + + it('toggleItalic wraps with single asterisks', () => { + const t = fakeView('hello', { from: 0, to: 5 }); + toggleItalic(t.view); + expect(t.doc()).toBe('*hello*'); + }); + + it('toggleStrikethrough wraps with ~~', () => { + const t = fakeView('hello', { from: 0, to: 5 }); + toggleStrikethrough(t.view); + expect(t.doc()).toBe('~~hello~~'); + }); + + it('toggleInlineCode wraps with backticks', () => { + const t = fakeView('hello', { from: 0, to: 5 }); + toggleInlineCode(t.view); + expect(t.doc()).toBe('`hello`'); + }); + }); + + describe('line-prefix commands', () => { + it('insertHeading prepends ## by default (level 2)', () => { + const t = fakeView('title', { from: 0, to: 0 }); + insertHeading(t.view); + expect(t.doc()).toBe('## title'); + }); + + it('insertHeading respects explicit level', () => { + const t = fakeView('title', { from: 0, to: 0 }); + insertHeading(t.view, 4); + expect(t.doc()).toBe('#### title'); + }); + + it('insertUnorderedList prepends - to the line', () => { + const t = fakeView('item', { from: 0, to: 0 }); + insertUnorderedList(t.view); + expect(t.doc()).toBe('- item'); + }); + + it('insertOrderedList prepends 1. to the line', () => { + const t = fakeView('item', { from: 0, to: 0 }); + insertOrderedList(t.view); + expect(t.doc()).toBe('1. item'); + }); + + it('insertCheckbox prepends - [ ] to the line', () => { + const t = fakeView('task', { from: 0, to: 0 }); + insertCheckbox(t.view); + expect(t.doc()).toBe('- [ ] task'); + }); + + it('insertQuote prepends > to the line', () => { + const t = fakeView('quoted', { from: 0, to: 0 }); + insertQuote(t.view); + expect(t.doc()).toBe('> quoted'); + }); + }); + + describe('block-insertion commands', () => { + 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('---'); + }); + }); +}); diff --git a/packages/commands/vitest.config.ts b/packages/commands/vitest.config.ts index b6aa913f..058ad3a5 100644 --- a/packages/commands/vitest.config.ts +++ b/packages/commands/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: false, environment: 'node', passWithNoTests: true, + coverage: sharedCoverage, }, }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 8996a048..edde6c9d 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['tests/**/*.test.ts'], + coverage: sharedCoverage, }, }); diff --git a/packages/embeds/vitest.config.ts b/packages/embeds/vitest.config.ts index 2dcea8c5..c2a23743 100644 --- a/packages/embeds/vitest.config.ts +++ b/packages/embeds/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: false, environment: 'node', + coverage: sharedCoverage, }, }); diff --git a/packages/licensing/README.md b/packages/licensing/README.md new file mode 100644 index 00000000..1f8bac37 --- /dev/null +++ b/packages/licensing/README.md @@ -0,0 +1,86 @@ +# @readied/licensing + +License and subscription verification helpers. + +## Subscription envelope (Ed25519) + +The desktop app caches its subscription state on disk. To prevent users from editing that file to grant themselves a paid plan, the **server signs every subscription payload with an Ed25519 private key**. The desktop verifies with an embedded public key. There is **no shared secret** on the client. + +### Wire format + +The server returns (and the client persists) a `SignedSubscriptionEnvelope`: + +```ts +{ + payload: { + payloadVersion: 1, + subscription: { /* SubscriptionInfo — id, customer, plan, status, period... */ }, + issuedAt: "2026-06-08T12:00:00.000Z", // when the server signed this + ttlSeconds?: 3600 // optional max age the server wants the client to honour + }, + signature: "" +} +``` + +The signature is computed over `canonicalJson(payload)` — a deterministic, sorted-key JSON encoding (see `canonicalJson` in `validator.ts`). Both sides MUST canonicalize identically, otherwise verification will fail even when the data is unchanged. + +### Server side (signing) + +```ts +import { signSubscriptionPayload } from '@readied/licensing'; + +const envelope = await signSubscriptionPayload( + { + payloadVersion: 1, + subscription: subscriptionInfoFromStripe, + issuedAt: new Date().toISOString(), + ttlSeconds: 3600, // optional + }, + process.env.LICENSE_SIGNING_PRIVATE_KEY! // 32-byte Ed25519 private key, hex +); + +return envelope; +``` + +- The private key MUST live only on the server. Never commit it. Rotate by generating a new keypair (`generateKeyPair`), updating the embedded public key in the desktop, and shipping a new release. +- `issuedAt` is mandatory — replay protection on the client uses it. + +### Client side (verification) + +```ts +import { verifySubscriptionSignature } from '@readied/licensing'; + +const result = await verifySubscriptionSignature(envelope, { + publicKey: SUBSCRIPTION_PUBLIC_KEY, // embedded in the desktop + // maxAgeSeconds: 24 * 3600, // optional; otherwise honours ttlSeconds +}); + +if (!result.valid) { + // Treat as not-subscribed. Log result.error. +} +``` + +### Embedded public key + +`DEFAULT_SUBSCRIPTION_PUBLIC_KEY` in `validator.ts` is a **placeholder** (`0000…`). It MUST be replaced with the actual server public key before shipping signed subscriptions. Callers may also pass `config.publicKey` explicitly, which is the form used by every internal consumer. + +### Replay & clock skew + +`verifySubscriptionSignature` rejects: + +- Envelopes older than `maxAgeSeconds` (defaults to `payload.ttlSeconds`, otherwise 7 days). +- Envelopes whose `issuedAt` is more than 60 seconds in the future (tolerates small clock skew between client and server). + +### What is NOT signed + +- **Trial state** (`trial.json`) is created entirely on the client when the user first starts a trial. There is no server-side trial registration. A determined user can extend their trial by editing the file. This is accepted: the trial is best-effort and the goal is to deter casual tampering, not stop a motivated attacker. Subscription is the real boundary. +- The **legacy license file** (`LicenseFile`) has its own signature scheme via `validateLicense` / `signLicense`, kept for backwards compatibility while the subscription model phases it out. + +## Rolling the signing key + +1. Generate a new keypair with `generateKeyPair()`. +2. Ship a desktop release with the new public key embedded. +3. Once enough clients have updated, switch the server to sign with the new private key. +4. Old clients with the previous public key will fail verification and treat users as not-subscribed until they update. + +Plan windowed rollouts accordingly — there is no client-side multi-key acceptance today. diff --git a/packages/licensing/__tests__/subscriptionSignature.test.ts b/packages/licensing/__tests__/subscriptionSignature.test.ts new file mode 100644 index 00000000..428a4a5f --- /dev/null +++ b/packages/licensing/__tests__/subscriptionSignature.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest'; +import { + canonicalJson, + signSubscriptionPayload, + verifySubscriptionSignature, + generateKeyPair, +} from '../src/validator.js'; +import type { SignedSubscriptionPayload, SubscriptionInfo } from '../src/types.js'; + +const futureIso = (offsetMs: number): string => new Date(Date.now() + offsetMs).toISOString(); + +function makeSubscription(overrides: Partial = {}): SubscriptionInfo { + return { + subscriptionId: 'sub_test_abc', + customerId: 'cus_test_xyz', + email: 'user@example.com', + plan: 'monthly', + status: 'active', + currentPeriodStart: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + currentPeriodEnd: futureIso(30 * 24 * 60 * 60 * 1000), + cancelAtPeriodEnd: false, + ...overrides, + }; +} + +function makePayload( + overrides: Partial = {} +): SignedSubscriptionPayload { + return { + payloadVersion: 1, + subscription: makeSubscription(), + issuedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('canonicalJson', () => { + it('emits sorted-key JSON regardless of insertion order', () => { + const a = canonicalJson({ b: 2, a: 1 }); + const b = canonicalJson({ a: 1, b: 2 }); + expect(a).toBe(b); + expect(a).toBe('{"a":1,"b":2}'); + }); + + it('recurses into nested objects', () => { + const a = canonicalJson({ outer: { z: 1, a: 2 } }); + expect(a).toBe('{"outer":{"a":2,"z":1}}'); + }); + + it('keeps arrays in their original order', () => { + expect(canonicalJson([3, 1, 2])).toBe('[3,1,2]'); + }); + + it('drops undefined fields like JSON.stringify does', () => { + expect(canonicalJson({ a: 1, b: undefined })).toBe('{"a":1}'); + }); + + it('handles primitives', () => { + expect(canonicalJson(null)).toBe('null'); + expect(canonicalJson(42)).toBe('42'); + expect(canonicalJson('x')).toBe('"x"'); + expect(canonicalJson(true)).toBe('true'); + }); +}); + +describe('signSubscriptionPayload + verifySubscriptionSignature', () => { + it('sign then verify round-trips with a fresh keypair', async () => { + const keys = await generateKeyPair(); + const payload = makePayload(); + const envelope = await signSubscriptionPayload(payload, keys.privateKey); + + expect(envelope.signature).toBeTruthy(); + expect(envelope.payload).toEqual(payload); + + const result = await verifySubscriptionSignature(envelope, { publicKey: keys.publicKey }); + expect(result.valid).toBe(true); + expect(result.subscription).toEqual(payload.subscription); + }); + + it('rejects an envelope signed with a different key', async () => { + const signer = await generateKeyPair(); + const other = await generateKeyPair(); + const envelope = await signSubscriptionPayload(makePayload(), signer.privateKey); + + const result = await verifySubscriptionSignature(envelope, { publicKey: other.publicKey }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/Signature verification failed/); + }); + + it('rejects a tampered payload', async () => { + const keys = await generateKeyPair(); + const envelope = await signSubscriptionPayload(makePayload(), keys.privateKey); + + const tampered = { + ...envelope, + payload: { + ...envelope.payload, + subscription: { + ...envelope.payload.subscription, + email: 'attacker@example.com', + }, + }, + }; + + const result = await verifySubscriptionSignature(tampered, { publicKey: keys.publicKey }); + expect(result.valid).toBe(false); + }); + + it('rejects unsupported payloadVersion', async () => { + const keys = await generateKeyPair(); + const envelope = await signSubscriptionPayload( + // @ts-expect-error — intentionally bad version + makePayload({ payloadVersion: 99 }), + keys.privateKey + ); + const result = await verifySubscriptionSignature(envelope, { publicKey: keys.publicKey }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/payload version/i); + }); + + it('rejects an envelope older than maxAgeSeconds', async () => { + const keys = await generateKeyPair(); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const envelope = await signSubscriptionPayload( + makePayload({ issuedAt: sevenDaysAgo }), + keys.privateKey + ); + const result = await verifySubscriptionSignature(envelope, { + publicKey: keys.publicKey, + maxAgeSeconds: 60 * 60, // 1 hour + }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/older than max age/); + }); + + it('honours per-envelope ttlSeconds when caller does not override', async () => { + const keys = await generateKeyPair(); + const issuedAt = new Date(Date.now() - 10 * 1000).toISOString(); + const envelope = await signSubscriptionPayload( + makePayload({ issuedAt, ttlSeconds: 5 }), + keys.privateKey + ); + const result = await verifySubscriptionSignature(envelope, { publicKey: keys.publicKey }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/older than max age/); + }); + + it('rejects envelopes timestamped far in the future', async () => { + const keys = await generateKeyPair(); + const inFiveMinutes = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const envelope = await signSubscriptionPayload( + makePayload({ issuedAt: inFiveMinutes }), + keys.privateKey + ); + const result = await verifySubscriptionSignature(envelope, { publicKey: keys.publicKey }); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/from the future/); + }); + + it('uses the injectable clock to make timing tests deterministic', async () => { + const keys = await generateKeyPair(); + const issuedAt = '2026-01-01T00:00:00.000Z'; + const envelope = await signSubscriptionPayload(makePayload({ issuedAt }), keys.privateKey); + const result = await verifySubscriptionSignature(envelope, { + publicKey: keys.publicKey, + // Set the clock 30 seconds after issuedAt — well inside any sensible TTL. + nowMs: new Date(issuedAt).getTime() + 30 * 1000, + }); + expect(result.valid).toBe(true); + }); + + it('rejects an inactive subscription even with a valid signature', async () => { + const keys = await generateKeyPair(); + const envelope = await signSubscriptionPayload( + makePayload({ + subscription: makeSubscription({ + status: 'canceled', + currentPeriodEnd: new Date(Date.now() - 1).toISOString(), + }), + }), + keys.privateKey + ); + const result = await verifySubscriptionSignature(envelope, { publicKey: keys.publicKey }); + expect(result.valid).toBe(false); + }); + + it('rejects envelopes missing required fields', async () => { + expect((await verifySubscriptionSignature(null)).valid).toBe(false); + expect((await verifySubscriptionSignature({})).valid).toBe(false); + expect((await verifySubscriptionSignature({ payload: makePayload() })).valid).toBe(false); + expect((await verifySubscriptionSignature({ signature: 'x', payload: null })).valid).toBe( + false + ); + }); +}); diff --git a/packages/licensing/src/index.ts b/packages/licensing/src/index.ts index 3bf35f06..bcd611ee 100644 --- a/packages/licensing/src/index.ts +++ b/packages/licensing/src/index.ts @@ -9,6 +9,8 @@ export type { StoredTrialData, StoredSubscriptionData, VerificationResult, + SignedSubscriptionPayload, + SignedSubscriptionEnvelope, // Legacy types (deprecated) LicenseFile, ActiveLicense, @@ -26,6 +28,9 @@ export { isCachedSubscriptionValid, createStoredSubscription, verifySubscription, + canonicalJson, + signSubscriptionPayload, + verifySubscriptionSignature, } from './validator.js'; // Trial diff --git a/packages/licensing/src/types.ts b/packages/licensing/src/types.ts index a9976ecc..f9635a31 100644 --- a/packages/licensing/src/types.ts +++ b/packages/licensing/src/types.ts @@ -75,12 +75,20 @@ export interface StoredTrialData { } /** - * Stored subscription data (cached locally) + * Stored subscription data (cached locally). + * + * `signedEnvelope` is the server-signed source of truth when present. + * `subscription` is the unsigned view derived from it (or, during the + * migration period before the server emits signed envelopes, the raw + * API response). Clients that have an envelope MUST verify it before + * trusting the cached subscription — see verifySubscriptionSignature. */ export interface StoredSubscriptionData { readonly subscription: SubscriptionInfo; readonly lastVerified: string; // ISO 8601 readonly cacheExpiresAt: string; // ISO 8601 + /** Signed envelope from the server. Optional during migration. */ + readonly signedEnvelope?: SignedSubscriptionEnvelope; } /** @@ -92,6 +100,38 @@ export interface VerificationResult { readonly subscription?: SubscriptionInfo; } +/** + * The exact payload that the server signs. + * + * Keep this stable — any change here invalidates every existing signature. + * When the schema needs to evolve, bump `payloadVersion` and let the client + * accept both versions during the transition. + */ +export interface SignedSubscriptionPayload { + readonly payloadVersion: 1; + /** The verified subscription state at the moment the server signed it. */ + readonly subscription: SubscriptionInfo; + /** When the server produced this signature (ISO 8601). */ + readonly issuedAt: string; + /** + * Optional max-age, in seconds. Lets the server tell the client how long + * to trust this signed copy before requiring a fresh fetch. + * If absent, the client applies its default policy. + */ + readonly ttlSeconds?: number; +} + +/** + * Envelope sent over the wire (and persisted on disk) — payload plus its + * Ed25519 signature. The signature is computed over a deterministic JSON + * encoding of `payload` so client and server produce identical bytes. + */ +export interface SignedSubscriptionEnvelope { + readonly payload: SignedSubscriptionPayload; + /** base64(Ed25519(canonicalJson(payload), serverPrivateKey)) */ + readonly signature: string; +} + // ============================================ // LEGACY TYPES (kept for migration, will remove) // ============================================ diff --git a/packages/licensing/src/validator.ts b/packages/licensing/src/validator.ts index 709ae293..23d5cec8 100644 --- a/packages/licensing/src/validator.ts +++ b/packages/licensing/src/validator.ts @@ -6,6 +6,8 @@ import type { VerificationResult, SubscriptionInfo, StoredSubscriptionData, + SignedSubscriptionPayload, + SignedSubscriptionEnvelope, } from './types.js'; /** @@ -14,6 +16,20 @@ import type { */ const DEFAULT_PUBLIC_KEY = '808de62a74a99bc70bf16f9df1ce3a7d6417e8d8479a6193df2bc28e6d510517'; +/** + * 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'; + /** * Extracts the payload portion of a license for signature verification */ @@ -357,3 +373,141 @@ export function createStoredSubscription( cacheExpiresAt: cacheExpires.toISOString(), }; } + +// ============================================================================ +// Signed Subscription Envelope (Ed25519) +// ============================================================================ + +/** + * Deterministic JSON encoder used as the signed message. + * + * Ed25519 signs bytes, not concepts — so the client and server MUST + * serialize the payload identically. JSON.stringify is non-deterministic + * across runtimes when objects have different insertion orders, so we + * sort keys alphabetically at every depth before stringifying. + * + * Arrays keep their order. Numbers, strings, booleans, null are emitted + * verbatim. Functions / undefined are stripped (as in JSON.stringify). + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return '[' + value.map(canonicalJson).join(',') + ']'; + } + const obj = value as Record; + const sortedKeys = Object.keys(obj).sort(); + const parts = sortedKeys + .filter(k => obj[k] !== undefined) + .map(k => JSON.stringify(k) + ':' + canonicalJson(obj[k])); + return '{' + parts.join(',') + '}'; +} + +/** + * Sign a subscription payload with the server's Ed25519 private key. + * + * Intended for SERVER use only. The client never holds the private key. + * + * @param payload - The payload to sign. Should include a fresh `issuedAt`. + * @param privateKeyHex - 32-byte Ed25519 private key, hex-encoded. + * @returns The signed envelope ready to send over the wire / persist. + */ +export async function signSubscriptionPayload( + payload: SignedSubscriptionPayload, + privateKeyHex: string +): Promise { + const privateKey = hexToBytes(privateKeyHex); + const message = stringToBytes(canonicalJson(payload)); + const signature = await ed.signAsync(message, privateKey); + return { + payload, + signature: bytesToBase64(signature), + }; +} + +/** + * Verify a subscription envelope received from the server (or read from a + * local cache file). + * + * Checks performed: + * 1. Envelope shape (payload + signature present). + * 2. Payload shape (payloadVersion, issuedAt, subscription). + * 3. Ed25519 signature against the public key. + * 4. Optional age check — if `maxAgeSeconds` is given, reject payloads + * whose `issuedAt` is older than that. + * 5. Subscription's own activity window (delegates to verifySubscription). + * + * @param envelope - The signed envelope to verify. + * @param config - Optional public key + age policy + injectable clock for tests. + */ +export async function verifySubscriptionSignature( + envelope: unknown, + config?: PublicKeyConfig & { + /** Max age of the signature, in seconds. Defaults to the envelope's + * own `ttlSeconds`, then to 7 days. */ + maxAgeSeconds?: number; + /** Injectable clock for tests. Defaults to Date.now(). */ + nowMs?: number; + } +): Promise { + if (typeof envelope !== 'object' || envelope === null) { + return { valid: false, error: 'Invalid envelope: not an object' }; + } + const env = envelope as Record; + if (typeof env.signature !== 'string' || env.signature.length === 0) { + return { valid: false, error: 'Invalid envelope: missing signature' }; + } + if (typeof env.payload !== 'object' || env.payload === null) { + return { valid: false, error: 'Invalid envelope: missing payload' }; + } + + const payload = env.payload as Record; + if (payload.payloadVersion !== 1) { + return { valid: false, error: 'Unsupported payload version' }; + } + if (typeof payload.issuedAt !== 'string') { + return { valid: false, error: 'Invalid envelope: missing issuedAt' }; + } + + // Verify Ed25519 signature. + try { + const publicKeyHex = config?.publicKey ?? DEFAULT_SUBSCRIPTION_PUBLIC_KEY; + const publicKey = hexToBytes(publicKeyHex); + const signature = base64ToBytes(env.signature as string); + const message = stringToBytes(canonicalJson(env.payload)); + const ok = await ed.verifyAsync(signature, message, publicKey); + if (!ok) { + return { valid: false, error: 'Signature verification failed' }; + } + } catch { + return { valid: false, error: 'Signature verification threw' }; + } + + // Replay window. The default below applies only when neither the + // envelope nor the caller provide one. + const DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; + const maxAgeSeconds = + config?.maxAgeSeconds ?? + (typeof payload.ttlSeconds === 'number' + ? (payload.ttlSeconds as number) + : DEFAULT_MAX_AGE_SECONDS); + const issuedAtMs = new Date(payload.issuedAt as string).getTime(); + const nowMs = config?.nowMs ?? Date.now(); + if (Number.isNaN(issuedAtMs)) { + return { valid: false, error: 'Invalid issuedAt' }; + } + if (nowMs - issuedAtMs > maxAgeSeconds * 1000) { + return { valid: false, error: 'Signed payload is older than max age' }; + } + if (issuedAtMs - nowMs > 60 * 1000) { + // Allow 60s clock skew but reject obviously-future timestamps. + return { valid: false, error: 'Signed payload is from the future' }; + } + + // Delegate subscription field/shape/expiry validation. + const inner = verifySubscription((env.payload as { subscription: unknown }).subscription); + if (!inner.valid) return inner; + + return { valid: true, subscription: inner.subscription }; +} diff --git a/packages/licensing/vitest.config.ts b/packages/licensing/vitest.config.ts index 8e730d50..0799f5fc 100644 --- a/packages/licensing/vitest.config.ts +++ b/packages/licensing/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', + coverage: sharedCoverage, }, }); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 53f1862b..d47df4b7 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -54,14 +54,16 @@ function createServer(db: Database) { // ── List notes ────────────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_list_notes', - 'List notes in Readied. Returns titles, IDs, and metadata.', { - notebook: z.string().optional().describe('Filter by notebook name'), - limit: z.number().default(20).describe('Max notes to return'), - includeTrash: z.boolean().default(false).describe('Include trashed notes'), - status: z.enum(['active', 'on_hold', 'completed', 'dropped']).optional(), + description: 'List notes in Readied. Returns titles, IDs, and metadata.', + inputSchema: { + notebook: z.string().optional().describe('Filter by notebook name'), + limit: z.number().default(20).describe('Max notes to return'), + includeTrash: z.boolean().default(false).describe('Include trashed notes'), + status: z.enum(['active', 'on_hold', 'completed', 'dropped']).optional(), + }, }, async ({ notebook, limit, includeTrash, status }) => { let sql = ` @@ -104,12 +106,14 @@ function createServer(db: Database) { // ── Read note ─────────────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_read_note', - 'Read the full content of a note by ID or title search.', { - id: z.string().optional().describe('Note ID (exact match)'), - title: z.string().optional().describe('Title to search for (partial match)'), + description: 'Read the full content of a note by ID or title search.', + inputSchema: { + id: z.string().optional().describe('Note ID (exact match)'), + title: z.string().optional().describe('Title to search for (FTS5)'), + }, }, async ({ id, title }) => { let note: Record | null = null; @@ -117,11 +121,29 @@ function createServer(db: Database) { if (id) { note = queryOne(db, 'SELECT id, title, content, notebook_id FROM notes WHERE id = ?', [id]); } else if (title) { + // Use FTS5 to find the best-matching live note by title. + // Falls back to a parameterized LIKE if FTS produced no hit, so this + // tool still works on freshly-restored DBs where the FTS index is empty. + const ftsQuery = prepareFtsQuery(title); note = queryOne( db, - 'SELECT id, title, content, notebook_id FROM notes WHERE title LIKE ? AND is_deleted = 0 ORDER BY updated_at DESC LIMIT 1', - [`%${title}%`] + `SELECT n.id, n.title, n.content, n.notebook_id + FROM notes_fts + JOIN notes n ON n.id = notes_fts.id + WHERE notes_fts MATCH ? AND n.is_deleted = 0 + ORDER BY bm25(notes_fts) LIMIT 1`, + [ftsQuery] ); + if (!note) { + note = queryOne( + db, + `SELECT id, title, content, notebook_id + FROM notes + WHERE title LIKE '%' || ? || '%' AND is_deleted = 0 + ORDER BY updated_at DESC LIMIT 1`, + [title] + ); + } } if (!note) { @@ -136,12 +158,14 @@ function createServer(db: Database) { // ── Create note ───────────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_create_note', - 'Create a new note in Readied. Content should be markdown.', { - content: z.string().describe('Markdown content for the note'), - notebook: z.string().optional().describe('Notebook name (defaults to Inbox)'), + description: 'Create a new note in Readied. Content should be markdown.', + inputSchema: { + content: z.string().describe('Markdown content for the note'), + notebook: z.string().optional().describe('Notebook name (defaults to Inbox)'), + }, }, async ({ content, notebook }) => { const id = crypto.randomUUID(); @@ -174,12 +198,14 @@ function createServer(db: Database) { // ── Update note ───────────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_update_note', - 'Update an existing note. Replaces the full content.', { - id: z.string().describe('Note ID'), - content: z.string().describe('New markdown content'), + description: 'Update an existing note. Replaces the full content.', + inputSchema: { + id: z.string().describe('Note ID'), + content: z.string().describe('New markdown content'), + }, }, async ({ id, content }) => { const now = new Date().toISOString(); @@ -205,12 +231,15 @@ function createServer(db: Database) { // ── Search notes (FTS5) ────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_search_notes', - 'Full-text search across all notes using FTS5 with relevance ranking. Returns matching notes with snippets.', { - query: z.string().describe('Search query'), - limit: z.number().default(10), + description: + 'Full-text search across all notes using FTS5 with relevance ranking. Returns matching notes with snippets.', + inputSchema: { + query: z.string().describe('Search query'), + limit: z.number().default(10), + }, }, async ({ query: q, limit }) => { const trimmed = q.trim(); @@ -246,30 +275,39 @@ function createServer(db: Database) { // ── List notebooks ────────────────────────────────────────────────────── - server.tool('readied_list_notebooks', 'List all notebooks in Readied.', {}, async () => { - const notebooks = query( - db, - `SELECT nb.id, nb.name, nb.parent_id, COUNT(n.id) as note_count + server.registerTool( + 'readied_list_notebooks', + { + description: 'List all notebooks in Readied.', + inputSchema: {}, + }, + async () => { + const notebooks = query( + db, + `SELECT nb.id, nb.name, nb.parent_id, COUNT(n.id) as note_count FROM notebooks nb LEFT JOIN notes n ON n.notebook_id = nb.id AND n.is_deleted = 0 GROUP BY nb.id ORDER BY nb.name` - ); + ); - const text = notebooks - .map(nb => `- **${nb.name}** (${nb.note_count} notes) — ID: ${nb.id}`) - .join('\n'); + const text = notebooks + .map(nb => `- **${nb.name}** (${nb.note_count} notes) — ID: ${nb.id}`) + .join('\n'); - return { content: [{ type: 'text' as const, text: text || 'No notebooks found.' }] }; - }); + return { content: [{ type: 'text' as const, text: text || 'No notebooks found.' }] }; + } + ); // ── Trash note ────────────────────────────────────────────────────────── - server.tool( + server.registerTool( 'readied_trash_note', - 'Move a note to trash (soft delete).', { - id: z.string().describe('Note ID'), + description: 'Move a note to trash (soft delete).', + inputSchema: { + id: z.string().describe('Note ID'), + }, }, async ({ id }) => { const changes = execute( diff --git a/packages/plugin-api/src/editor/types.ts b/packages/plugin-api/src/editor/types.ts deleted file mode 100644 index 6ec32d25..00000000 --- a/packages/plugin-api/src/editor/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type { EditorAPI } from '../types'; diff --git a/packages/plugin-api/vitest.config.ts b/packages/plugin-api/vitest.config.ts index 8996a048..edde6c9d 100644 --- a/packages/plugin-api/vitest.config.ts +++ b/packages/plugin-api/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['tests/**/*.test.ts'], + coverage: sharedCoverage, }, }); diff --git a/packages/product-config/vitest.config.ts b/packages/product-config/vitest.config.ts index 8e730d50..0799f5fc 100644 --- a/packages/product-config/vitest.config.ts +++ b/packages/product-config/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', + coverage: sharedCoverage, }, }); diff --git a/packages/storage-core/src/interfaces/index.ts b/packages/storage-core/src/interfaces/index.ts deleted file mode 100644 index 64f23885..00000000 --- a/packages/storage-core/src/interfaces/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Interface exports - */ - -export type { DatabaseAdapter, PreparedStatement, StatementResult } from './DatabaseAdapter.js'; - -export type { Migration, MigrationRecord } from './Migration.js'; - -export type { ExtendedNoteRepository } from './ExtendedNoteRepository.js'; diff --git a/packages/storage-core/src/migrations/index.ts b/packages/storage-core/src/migrations/index.ts deleted file mode 100644 index 39325bd2..00000000 --- a/packages/storage-core/src/migrations/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Migration exports - */ - -export { runMigrations, getPendingMigrations, getCurrentVersion } from './runner.js'; diff --git a/packages/storage-core/src/repositories/index.ts b/packages/storage-core/src/repositories/index.ts deleted file mode 100644 index 4558a7f8..00000000 --- a/packages/storage-core/src/repositories/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Repository exports - */ - -export { InMemoryNoteRepository } from './InMemoryNoteRepository.js'; diff --git a/packages/storage-core/src/types/index.ts b/packages/storage-core/src/types/index.ts deleted file mode 100644 index 753040ca..00000000 --- a/packages/storage-core/src/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Type exports - */ - -export type { ArchivedFilter } from './ArchivedFilter.js'; -export type { ListNotesOptions } from './ListNotesOptions.js'; -export type { NoteSnapshot } from './NoteSnapshot.js'; diff --git a/packages/storage-core/vitest.config.ts b/packages/storage-core/vitest.config.ts index 2dcea8c5..c2a23743 100644 --- a/packages/storage-core/vitest.config.ts +++ b/packages/storage-core/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: false, environment: 'node', + coverage: sharedCoverage, }, }); diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 32bf0aa6..c92b3353 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -4,25 +4,23 @@ * Implements the ExtendedNoteRepository interface from @readied/storage-core */ -import type { - ExtendedNoteRepository, - ListNotesOptions, - ArchivedFilter, -} from '@readied/storage-core'; -import { - type Note, - type NoteId, - type NoteStatus, - type Tag, - type Timestamp, - createNote, - createNoteId, - createNotebookId, - createTag, - DEFAULT_NOTE_STATUS, -} from '@readied/core'; +import type { ExtendedNoteRepository, ListNotesOptions } from '@readied/storage-core'; +import { type Note, type NoteId, type Tag, createNoteId, createTag } from '@readied/core'; import { extractWikilinks } from '@readied/wikilinks'; import type { DatabaseConnection } from '../database.js'; +import { + rowToNote, + prepareFtsQuery, + archivedConditionSql, + type NoteRow, + type TagRow, + type TagWithColorRow, + type BacklinkInfo, +} from './noteMapping.js'; + +// Re-export public types so external imports (e.g. desktop's handlers/types.ts) +// keep working unchanged. +export type { BacklinkInfo }; /** Sync history entry returned by getSyncHistory */ export interface SyncHistoryEntry { @@ -42,37 +40,6 @@ export interface SyncHistoryEntry { errorMessage: string | null; } -/** Row type from SQLite */ -interface NoteRow { - id: string; - notebook_id: string; - content: string; - title: string; - created_at: string; - updated_at: string; - word_count: number; - archived_at: string | null; - is_pinned: number; // SQLite stores booleans as 0/1 - is_deleted: number; - status: string; -} - -interface TagRow { - name: string; -} - -interface TagWithColorRow { - name: string; - color: string | null; -} - -/** Backlink information for UI display */ -export interface BacklinkInfo { - noteId: string; - noteTitle: string; - targetRef: string; -} - /** SQLite implementation of ExtendedNoteRepository */ export class SQLiteNoteRepository implements ExtendedNoteRepository { constructor(private readonly db: DatabaseConnection) {} @@ -90,7 +57,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { if (!row) return null; const tags = this.getTagsForNote(id); - return this.rowToNote(row, tags); + return rowToNote(row, tags); } /** Save a note (insert or update) */ @@ -156,7 +123,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { title: 'title', }[sortBy]; - const archivedCondition = this.getArchivedCondition(archived, 'n'); + const archivedCondition = archivedConditionSql(archived, 'n'); let sql: string; let params: (string | number)[]; @@ -189,7 +156,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { return rows.map(row => { const tags = this.getTagsForNote(createNoteId(row.id)); - return this.rowToNote(row, tags); + return rowToNote(row, tags); }); } @@ -207,7 +174,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { const archivedCondition = includeArchived ? '' : 'AND n.archived_at IS NULL'; // Prepare FTS5 query: escape special chars, add prefix matching - const ftsQuery = this.prepareFtsQuery(trimmedQuery); + const ftsQuery = prepareFtsQuery(trimmedQuery); const stmt = this.db.prepare(` SELECT n.id, n.notebook_id, n.content, n.title, n.created_at, n.updated_at, @@ -223,26 +190,11 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { return rows.map(row => { const tags = this.getTagsForNote(createNoteId(row.id)); - return this.rowToNote(row, tags); + return rowToNote(row, tags); }); } - /** Prepare query string for FTS5 MATCH syntax */ - private prepareFtsQuery(query: string): string { - // Escape FTS5 special characters: " * ^ - OR AND NOT ( ) - const escaped = query.replace(/["*^()]/g, ' ').trim(); - - // Split into terms and add prefix matching for partial word search - const terms = escaped.split(/\s+/).filter(t => t.length > 0); - - if (terms.length === 0) { - return '""'; // Empty search - } - - // Use OR between terms with prefix matching - // Each term becomes "term"* for prefix matching - return terms.map(t => `"${t}"*`).join(' OR '); - } + // prepareFtsQuery moved to noteMapping.ts /** Get total count of notes */ async count(includeArchived: boolean = false): Promise { @@ -283,17 +235,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { // Private helpers - private getArchivedCondition(filter: ArchivedFilter, tableAlias: string = ''): string { - const prefix = tableAlias ? `${tableAlias}.` : ''; - switch (filter) { - case 'active': - return `AND ${prefix}archived_at IS NULL`; - case 'archived': - return `AND ${prefix}archived_at IS NOT NULL`; - case 'all': - return ''; - } - } + // getArchivedCondition moved to noteMapping.archivedConditionSql private getTagsForNote(noteId: NoteId): Tag[] { const stmt = this.db.prepare(` @@ -478,38 +420,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { } } - private rowToNote(row: NoteRow, tags: Tag[]): Note { - // Reconstruct note from stored data with structural title - const note = createNote({ - id: createNoteId(row.id), - notebookId: createNotebookId(row.notebook_id), - title: row.title, // Structural title from DB - 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 with stored metadata - return { - ...note, - notebookId: createNotebookId(row.notebook_id), - title: row.title, // Ensure structural title is set - 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, - }, - }; - } + // rowToNote moved to noteMapping.ts // ═══════════════════════════════════════════════════════════════════════════ // Links (Wikilinks / Backlinks) @@ -737,7 +648,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { return rows.map(row => { const tags = this.getTagsForNote(createNoteId(row.id)); return { - note: this.rowToNote(row, tags), + note: rowToNote(row, tags), localVersion: row.local_version, lastSyncedAt: row.last_synced_at, }; diff --git a/packages/storage-sqlite/src/repositories/index.ts b/packages/storage-sqlite/src/repositories/index.ts deleted file mode 100644 index 61999496..00000000 --- a/packages/storage-sqlite/src/repositories/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Repository exports - */ - -export { SQLiteNoteRepository, type BacklinkInfo } from './SQLiteNoteRepository.js'; diff --git a/packages/storage-sqlite/src/repositories/noteMapping.ts b/packages/storage-sqlite/src/repositories/noteMapping.ts new file mode 100644 index 00000000..0ea9ae35 --- /dev/null +++ b/packages/storage-sqlite/src/repositories/noteMapping.ts @@ -0,0 +1,130 @@ +/** + * Row → Note mapping helpers and shared row types. + * + * Pure functions extracted from SQLiteNoteRepository so future + * sync / tag / archive sub-repositories can reuse them without + * depending on the main repo class. + */ + +import { + type Note, + type NoteStatus, + type Tag, + type Timestamp, + createNote, + createNoteId, + createNotebookId, + DEFAULT_NOTE_STATUS, +} from '@readied/core'; +import type { ArchivedFilter } from '@readied/storage-core'; + +/** Row shape returned by `SELECT * FROM notes` */ +export interface NoteRow { + id: string; + notebook_id: string; + content: string; + title: string; + created_at: string; + updated_at: string; + word_count: number; + archived_at: string | null; + is_pinned: number; // SQLite stores booleans as 0/1 + is_deleted: number; + status: string; +} + +/** Row shape for tag joins (just the tag name) */ +export interface TagRow { + name: string; +} + +/** Row shape for tags with their color metadata */ +export interface TagWithColorRow { + name: string; + color: string | null; +} + +/** Backlink information surfaced to the UI */ +export interface BacklinkInfo { + noteId: string; + noteTitle: string; + targetRef: string; +} + +/** + * Reconstruct a domain Note from a SQLite row plus its tags. + * + * The row carries the *stored* (structural) title — the markdown-derived + * "display" title lives elsewhere. We reuse `createNote` to get fresh + * metadata defaults, then overlay the persisted values so that + * createdAt / updatedAt / wordCount / archivedAt survive the roundtrip. + */ +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, + }, + }; +} + +/** + * Build an FTS5 MATCH clause from a free-form user query. + * + * 1. Strip FTS5 special chars (" * ^ ( )) — we'll add our own. + * 2. Tokenize on whitespace. + * 3. Quote each token (defends against tokens that look like FTS keywords) + * and append `*` for prefix-matching. + * 4. Join with OR — any token match counts. + * + * Empty / all-whitespace input returns `""`, which FTS5 treats as "no + * results" rather than throwing. + */ +export function prepareFtsQuery(input: string): string { + const escaped = input.replace(/["*^()]/g, ' ').trim(); + const terms = escaped.split(/\s+/).filter(t => t.length > 0); + if (terms.length === 0) return '""'; + return terms.map(t => `"${t}"*`).join(' OR '); +} + +/** + * Build a SQL fragment that filters by archived state. + * + * Returns either an empty string (no filter) or a SQL chunk starting + * with `AND`. Caller is responsible for the WHERE. + * + * @param tableAlias prefix without trailing dot, e.g. `n` → emits `n.archived_at` + */ +export function archivedConditionSql(filter: ArchivedFilter, tableAlias: string = ''): string { + const prefix = tableAlias ? `${tableAlias}.` : ''; + switch (filter) { + case 'active': + return `AND ${prefix}archived_at IS NULL`; + case 'archived': + return `AND ${prefix}archived_at IS NOT NULL`; + case 'all': + return ''; + } +} diff --git a/packages/storage-sqlite/vitest.config.ts b/packages/storage-sqlite/vitest.config.ts index ec5d3a41..a907b681 100644 --- a/packages/storage-sqlite/vitest.config.ts +++ b/packages/storage-sqlite/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: false, environment: 'node', setupFiles: ['./tests/setup.ts'], + coverage: sharedCoverage, }, }); diff --git a/packages/sync-core/package.json b/packages/sync-core/package.json index 1725f078..b98f2a7a 100644 --- a/packages/sync-core/package.json +++ b/packages/sync-core/package.json @@ -18,7 +18,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@readied/core": "workspace:*", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/tasks/vitest.config.ts b/packages/tasks/vitest.config.ts index 2dcea8c5..c2a23743 100644 --- a/packages/tasks/vitest.config.ts +++ b/packages/tasks/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: false, environment: 'node', + coverage: sharedCoverage, }, }); diff --git a/packages/wikilinks/package.json b/packages/wikilinks/package.json index 6927d746..9f975772 100644 --- a/packages/wikilinks/package.json +++ b/packages/wikilinks/package.json @@ -23,7 +23,6 @@ "@codemirror/view": "^6.0.0" }, "dependencies": { - "unified": "^11.0.0", "unist-util-visit": "^5.1.0" }, "devDependencies": { diff --git a/packages/wikilinks/vitest.config.ts b/packages/wikilinks/vitest.config.ts index 8996a048..edde6c9d 100644 --- a/packages/wikilinks/vitest.config.ts +++ b/packages/wikilinks/vitest.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'vitest/config'; +import { sharedCoverage } from '../../vitest.shared.js'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['tests/**/*.test.ts'], + coverage: sharedCoverage, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9c0fcaa..a07a973b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@semantic-release/release-notes-generator': specifier: ^14.1.1 version: 14.1.1(semantic-release@25.0.3(typescript@6.0.3)) + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) conventional-changelog-conventionalcommits: specifier: ^9.3.1 version: 9.3.1 @@ -44,9 +47,12 @@ importers: eslint-plugin-import-x: specifier: ^4.16.2 version: 4.16.2(@typescript-eslint/utils@8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.1(jiti@2.7.0)) - husky: - specifier: ^9.1.7 - version: 9.1.7 + knip: + specifier: ^5.66.0 + version: 5.88.1(@types/node@25.9.2)(typescript@6.0.3) + lefthook: + specifier: ^1.13.6 + version: 1.13.6 lint-staged: specifier: ^17.0.7 version: 17.0.7 @@ -67,7 +73,7 @@ importers: version: 8.60.1(eslint@10.4.1(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/desktop: dependencies: @@ -113,9 +119,6 @@ importers: electron-updater: specifier: ^6.8.9 version: 6.8.9 - highlight.js: - specifier: ^11.11.1 - version: 11.11.1 isomorphic-git: specifier: ^1.38.4 version: 1.38.4 @@ -125,15 +128,9 @@ importers: pino: specifier: ^10.3.1 version: 10.3.1 - pino-roll: - specifier: ^4.0.0 - version: 4.0.0 react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) - react-resizable-panels: - specifier: ^4.11.2 - version: 4.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rehype-highlight: specifier: ^7.0.2 version: 7.0.2 @@ -146,13 +143,16 @@ importers: turndown-plugin-gfm: specifier: ^1.0.2 version: 1.0.2 - unist-util-visit: - specifier: ^5.1.0 - version: 5.1.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) devDependencies: + '@playwright/test': + specifier: ^1.49.1 + version: 1.60.0 '@readied/ai-core': specifier: workspace:* version: link:../../packages/ai-core @@ -192,9 +192,6 @@ importers: '@types/better-sqlite3': specifier: ^7.6.12 version: 7.6.13 - '@types/mdast': - specifier: ^4.0.4 - version: 4.0.4 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -219,9 +216,6 @@ importers: electron-vite: specifier: ^5.0.0 version: 5.0.0(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 react: specifier: ^19.2.7 version: 19.2.7 @@ -242,7 +236,7 @@ importers: version: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/web: dependencies: @@ -272,13 +266,13 @@ importers: version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) fumadocs-core: specifier: ^16.9.3 - version: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + version: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) fumadocs-mdx: specifier: ^15.0.11 - version: 15.0.11(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 15.0.11(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) fumadocs-ui: specifier: ^16.9.3 - version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0) + version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0) lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.7) @@ -287,7 +281,7 @@ importers: version: 18.0.5 next: specifier: ^16.2.7 - version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -339,7 +333,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/api: dependencies: @@ -376,7 +370,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) wrangler: specifier: ^4.98.0 version: 4.98.0(@cloudflare/workers-types@4.20260608.1) @@ -388,7 +382,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/commands: devDependencies: @@ -406,7 +400,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/core: dependencies: @@ -419,7 +413,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/embeds: dependencies: @@ -441,7 +435,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/licensing: dependencies: @@ -460,7 +454,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/mcp-server: dependencies: @@ -482,7 +476,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/plugin-api: devDependencies: @@ -503,7 +497,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) zustand: specifier: ^5.0.14 version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) @@ -518,7 +512,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/product-config: devDependencies: @@ -527,7 +521,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/storage-core: dependencies: @@ -543,7 +537,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/storage-sqlite: dependencies: @@ -568,13 +562,10 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/sync-core: dependencies: - '@readied/core': - specifier: workspace:* - version: link:../core zod: specifier: ^4.4.3 version: 4.4.3 @@ -584,7 +575,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/tasks: devDependencies: @@ -593,13 +584,10 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/wikilinks: dependencies: - unified: - specifier: ^11.0.0 - version: 11.0.5 unist-util-visit: specifier: ^5.1.0 version: 5.1.0 @@ -621,7 +609,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -746,10 +734,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -763,6 +759,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-arrow-functions@7.27.1': resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} engines: {node: '>=6.9.0'} @@ -781,6 +782,14 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -1032,8 +1041,8 @@ packages: resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} engines: {node: '>=22.12.0'} - '@electron/node-gyp@git+https://git@github.com:electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2': - resolution: {commit: 06b29aafb7708acef8b3669835c8a7857ebc92d2, repo: git@github.com:electron/node-gyp.git, type: git} + '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': + resolution: {tarball: https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2} version: 10.2.0-electron.1 engines: {node: '>=12.13.0'} hasBin: true @@ -2191,6 +2200,18 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@npmcli/fs@2.1.2': resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2447,6 +2468,101 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.20.0': + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.20.0': + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + cpu: [x64] + os: [win32] + '@package-json/types@0.0.12': resolution: {integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==} @@ -2474,6 +2590,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -3562,6 +3683,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.8': resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} @@ -3744,6 +3874,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -4068,9 +4201,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -4301,12 +4431,6 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - - dateformat@4.6.3: - resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4843,27 +4967,31 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -4945,6 +5073,11 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -5004,6 +5137,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5191,6 +5329,10 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -5296,9 +5438,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -5326,6 +5465,9 @@ packages: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -5382,11 +5524,6 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5602,6 +5739,18 @@ packages: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -5629,13 +5778,12 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5703,16 +5851,77 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@5.88.1: + resolution: {integrity: sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==} + engines: {node: '>=18.18.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4 <7' + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lefthook-darwin-arm64@1.13.6: + resolution: {integrity: sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@1.13.6: + resolution: {integrity: sha512-CoRpdzanu9RK3oXR1vbEJA5LN7iB+c7hP+sONeQJzoOXuq4PNKVtEaN84Gl1BrVtCNLHWFAvCQaZPPiiXSy8qg==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@1.13.6: + resolution: {integrity: sha512-X4A7yfvAJ68CoHTqP+XvQzdKbyd935sYy0bQT6Ajz7FL1g7hFiro8dqHSdPdkwei9hs8hXeV7feyTXbYmfjKQQ==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@1.13.6: + resolution: {integrity: sha512-ai2m+Sj2kGdY46USfBrCqLKe9GYhzeq01nuyDYCrdGISePeZ6udOlD1k3lQKJGQCHb0bRz4St0r5nKDSh1x/2A==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@1.13.6: + resolution: {integrity: sha512-cbo4Wtdq81GTABvikLORJsAWPKAJXE8Q5RXsICFUVznh5PHigS9dFW/4NXywo0+jfFPCT6SYds2zz4tCx6DA0Q==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@1.13.6: + resolution: {integrity: sha512-uJl9vjCIIBTBvMZkemxCE+3zrZHlRO7Oc+nZJ+o9Oea3fu+W82jwX7a7clw8jqNfaeBS+8+ZEQgiMHWCloTsGw==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@1.13.6: + resolution: {integrity: sha512-7r153dxrNRQ9ytRs2PmGKKkYdvZYFPre7My7XToSTiRu5jNCq++++eAKVkoyWPduk97dGIA+YWiEr5Noe0TK2A==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@1.13.6: + resolution: {integrity: sha512-Z+UhLlcg1xrXOidK3aLLpgH7KrwNyWYE3yb7ITYnzJSEV8qXnePtVu8lvMBHs/myzemjBzeIr/U/+ipjclR06g==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@1.13.6: + resolution: {integrity: sha512-Uxef6qoDxCmUNQwk8eBvddYJKSBFglfwAY9Y9+NnnmiHpWTjjYiObE9gT2mvGVpEgZRJVAatBXc+Ha5oDD/OgQ==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@1.13.6: + resolution: {integrity: sha512-mOZoM3FQh3o08M8PQ/b3IYuL5oo36D9ehczIw1dAgp1Ly+Tr4fJ96A+4SEJrQuYeRD4mex9bR7Ps56I73sBSZA==} + cpu: [x64] + os: [win32] + + lefthook@1.13.6: + resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==} + hasBin: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} - cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lie@3.3.0: @@ -5891,10 +6100,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-fetch-happen@10.2.1: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -5996,6 +6212,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -6515,6 +6735,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + oxc-resolver@11.20.0: + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -6704,13 +6927,6 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true - - pino-roll@4.0.0: - resolution: {integrity: sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg==} - pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} @@ -6730,6 +6946,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -6864,9 +7090,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -6885,6 +7108,9 @@ packages: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -6950,12 +7176,6 @@ packages: '@types/react': optional: true - react-resizable-panels@4.11.2: - resolution: {integrity: sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -7108,6 +7328,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -7134,6 +7358,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -7160,9 +7387,6 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - semantic-release@25.0.3: resolution: {integrity: sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==} engines: {node: ^22.14.0 || >= 24.10.0} @@ -7306,6 +7530,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + socks-proxy-agent@7.0.0: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} @@ -7314,9 +7542,6 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.0: - resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7733,6 +7958,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + unbash@2.2.0: + resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} + engines: {node: '>=14'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -7987,6 +8216,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -8391,8 +8624,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -8404,6 +8641,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -8432,6 +8673,13 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1)': @@ -8875,7 +9123,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/node-gyp@git+https://git@github.com:electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2': + '@electron/node-gyp@https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2': dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 @@ -8923,7 +9171,7 @@ snapshots: '@electron/rebuild@3.7.0': dependencies: - '@electron/node-gyp': git+https://git@github.com:electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2 + '@electron/node-gyp': https://codeload.github.com/electron/node-gyp/tar.gz/06b29aafb7708acef8b3669835c8a7857ebc92d2 '@malept/cross-spawn-promise': 2.0.0 chalk: 4.1.2 debug: 4.4.3 @@ -9834,6 +10082,18 @@ snapshots: '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 @@ -10154,6 +10414,67 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + optional: true + + '@oxc-resolver/binding-android-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + optional: true + '@package-json/types@0.0.12': {} '@peculiar/asn1-schema@2.7.0': @@ -10186,6 +10507,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -11279,6 +11604,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 @@ -11547,6 +11886,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.3: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-exit-hook@2.0.1: {} @@ -11910,8 +12255,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -12126,10 +12469,6 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - date-fns@4.1.0: {} - - dateformat@4.6.3: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -12804,20 +13143,32 @@ snapshots: fast-content-type-parse@3.0.0: {} - fast-copy@4.0.2: {} - fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fast-safe-stringify@2.1.1: {} - fast-uri@3.1.0: {} fast-uri@3.1.2: {} + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -12923,6 +13274,10 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + forwarded-parse@2.1.2: {} forwarded@0.2.0: {} @@ -12983,10 +13338,13 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true - fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): + fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -13013,21 +13371,21 @@ snapshots: '@types/react': 19.2.17 algoliasearch: 5.46.2 lucide-react: 1.17.0(react@19.2.7) - next: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.0.11(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + fumadocs-mdx@15.0.11(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) js-yaml: 4.2.0 mdast-util-mdx: 3.0.0 picocolors: 1.1.1 @@ -13043,14 +13401,14 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 rolldown: 1.0.3 vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0): + fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -13064,7 +13422,7 @@ snapshots: '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-tabs': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: 0.7.1 - fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) + fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.17)(algoliasearch@5.46.2)(lucide-react@1.17.0(react@19.2.7))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.4.3) lucide-react: 1.17.0(react@19.2.7) motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-themes: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -13079,7 +13437,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@tailwindcss/oxide' @@ -13170,6 +13528,10 @@ snapshots: github-slugger@2.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -13386,8 +13748,6 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 - help-me@5.0.0: {} - highlight.js@10.7.3: {} highlight.js@11.11.1: {} @@ -13408,6 +13768,8 @@ snapshots: dependencies: lru-cache: 11.2.6 + html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -13480,8 +13842,6 @@ snapshots: dependencies: ms: 2.1.3 - husky@9.1.7: {} - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -13653,6 +14013,19 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -13675,10 +14048,10 @@ snapshots: jose@6.2.3: {} - joycon@3.1.1: {} - js-base64@3.7.8: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -13741,8 +14114,69 @@ snapshots: kleur@4.1.5: {} + knip@5.88.1(@types/node@25.9.2)(typescript@6.0.3): + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@types/node': 25.9.2 + fast-glob: 3.3.3 + formatly: 0.3.0 + jiti: 2.7.0 + minimist: 1.2.8 + oxc-resolver: 11.20.0 + picocolors: 1.1.1 + picomatch: 4.0.4 + smol-toml: 1.6.1 + strip-json-comments: 5.0.3 + typescript: 6.0.3 + unbash: 2.2.0 + yaml: 2.9.0 + zod: 4.4.3 + lazy-val@1.0.5: {} + lefthook-darwin-arm64@1.13.6: + optional: true + + lefthook-darwin-x64@1.13.6: + optional: true + + lefthook-freebsd-arm64@1.13.6: + optional: true + + lefthook-freebsd-x64@1.13.6: + optional: true + + lefthook-linux-arm64@1.13.6: + optional: true + + lefthook-linux-x64@1.13.6: + optional: true + + lefthook-openbsd-arm64@1.13.6: + optional: true + + lefthook-openbsd-x64@1.13.6: + optional: true + + lefthook-windows-arm64@1.13.6: + optional: true + + lefthook-windows-x64@1.13.6: + optional: true + + lefthook@1.13.6: + optionalDependencies: + lefthook-darwin-arm64: 1.13.6 + lefthook-darwin-x64: 1.13.6 + lefthook-freebsd-arm64: 1.13.6 + lefthook-freebsd-x64: 1.13.6 + lefthook-linux-arm64: 1.13.6 + lefthook-linux-x64: 1.13.6 + lefthook-openbsd-arm64: 1.13.6 + lefthook-openbsd-x64: 1.13.6 + lefthook-windows-arm64: 1.13.6 + lefthook-windows-x64: 1.13.6 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -13920,12 +14354,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.8.2 + make-fetch-happen@10.2.1: dependencies: agentkeepalive: 4.6.0 @@ -14162,6 +14606,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -14585,7 +15031,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.7 '@swc/helpers': 0.5.15 @@ -14605,6 +15051,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.7 '@next/swc-win32-x64-msvc': 16.2.7 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -14754,6 +15201,28 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + oxc-resolver@11.20.0: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 + '@oxc-resolver/binding-android-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-x64': 11.20.0 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + p-cancelable@2.1.1: {} p-each-series@3.0.0: {} @@ -14918,27 +15387,6 @@ snapshots: dependencies: split2: 4.2.0 - pino-pretty@13.1.3: - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 4.0.2 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pump: 3.0.3 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.0 - strip-json-comments: 5.0.3 - - pino-roll@4.0.0: - dependencies: - date-fns: 4.1.0 - sonic-boom: 4.2.0 - pino-std-serializers@7.1.0: {} pino@10.3.1: @@ -14971,6 +15419,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -15092,11 +15548,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -15114,6 +15565,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {} @@ -15190,11 +15643,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-resizable-panels@4.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): dependencies: get-nonce: 1.0.1 @@ -15421,6 +15869,8 @@ snapshots: retry@0.12.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} rimraf@2.6.3: @@ -15472,6 +15922,10 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -15492,8 +15946,6 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - secure-json-parse@4.1.0: {} - semantic-release@25.0.3(typescript@6.0.3): dependencies: '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.3(typescript@6.0.3)) @@ -15713,6 +16165,8 @@ snapshots: smart-buffer@4.2.0: {} + smol-toml@1.6.1: {} + socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 @@ -15726,10 +16180,6 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 - sonic-boom@4.2.0: - dependencies: - atomic-sleep: 1.0.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -16127,6 +16577,8 @@ snapshots: uglify-js@3.19.3: optional: true + unbash@2.2.0: {} + undici-types@6.21.0: {} undici-types@7.18.2: {} @@ -16332,7 +16784,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -16357,11 +16809,14 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.2 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) transitivePeerDependencies: - msw w3c-keyname@2.2.8: {} + walk-up-path@4.0.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -16490,8 +16945,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.9.0: - optional: true + yaml@2.9.0: {} yargs-parser@20.2.9: {} diff --git a/scripts/bump-version.js b/scripts/bump-version.js deleted file mode 100644 index 7dcb54d1..00000000 --- a/scripts/bump-version.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import { readFileSync, writeFileSync } from 'fs'; -import { resolve } from 'path'; - -const version = process.argv[2]; -if (!version) { - console.error('Usage: bump-version.js '); - process.exit(1); -} - -if ( - !/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/.test( - version - ) -) { - console.error(`Invalid version format: ${version}`); - process.exit(1); -} - -const files = ['package.json', 'apps/desktop/package.json']; - -for (const file of files) { - const path = resolve(file); - try { - const pkg = JSON.parse(readFileSync(path, 'utf8')); - pkg.version = version; - writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); - console.log(`Updated ${file} -> ${version}`); - } catch (err) { - console.error(`Failed to update ${file}: ${err.message}`); - process.exit(1); - } -} diff --git a/vitest.shared.ts b/vitest.shared.ts new file mode 100644 index 00000000..48b3e069 --- /dev/null +++ b/vitest.shared.ts @@ -0,0 +1,23 @@ +/** + * Shared vitest configuration fragments. + * + * Each package's vitest.config.ts can spread these to opt into consistent + * coverage reporting. Thresholds are NOT enforced yet — this is baseline + * measurement only. When per-package floors are known, replace `undefined` + * with `{ lines: N, functions: N, branches: N }` in that package's config. + */ +import type { UserConfig } from 'vitest/config'; + +export const sharedCoverage: NonNullable['coverage']> = { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + '**/*.test.{ts,tsx}', + '**/__tests__/**', + '**/tests/**', + '**/dist/**', + '**/*.d.ts', + '**/index.{ts,tsx}', + ], +};