@knotpad/app 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/app/(app)/calendar/page.tsx +57 -0
  2. package/app/(app)/error.tsx +35 -0
  3. package/app/(app)/graph/page.tsx +32 -0
  4. package/app/(app)/guide/page.tsx +21 -0
  5. package/app/(app)/kanban/loading.tsx +24 -0
  6. package/app/(app)/kanban/page.tsx +59 -0
  7. package/app/(app)/layout.tsx +122 -0
  8. package/app/(app)/list/loading.tsx +21 -0
  9. package/app/(app)/list/page.tsx +137 -0
  10. package/app/(app)/loading.tsx +18 -0
  11. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  12. package/app/(app)/notes/layout.tsx +30 -0
  13. package/app/(app)/notes/page.tsx +39 -0
  14. package/app/(app)/page.tsx +5 -0
  15. package/app/(app)/settings/agent-token/page.tsx +59 -0
  16. package/app/(app)/settings/backup/page.tsx +49 -0
  17. package/app/(app)/settings/billing/page.tsx +53 -0
  18. package/app/(app)/settings/calendar/page.tsx +41 -0
  19. package/app/(app)/settings/layout.test.tsx +39 -0
  20. package/app/(app)/settings/layout.tsx +71 -0
  21. package/app/(app)/settings/page.tsx +4 -0
  22. package/app/(app)/settings/security/page.tsx +43 -0
  23. package/app/(app)/settings/team/page.tsx +74 -0
  24. package/app/(app)/settings/workspace/page.tsx +27 -0
  25. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  26. package/app/(auth)/forgot-password/page.tsx +106 -0
  27. package/app/(auth)/guest/page.tsx +56 -0
  28. package/app/(auth)/layout.tsx +13 -0
  29. package/app/(auth)/login/page.tsx +14 -0
  30. package/app/(auth)/register/page.tsx +193 -0
  31. package/app/(auth)/reset-password/page.tsx +138 -0
  32. package/app/api/account/claim/route.tsx +135 -0
  33. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  34. package/app/api/admin/license/route.tsx +42 -0
  35. package/app/api/auth/2fa/route.tsx +148 -0
  36. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  37. package/app/api/auth/change-password/route.tsx +61 -0
  38. package/app/api/auth/check-2fa/route.tsx +19 -0
  39. package/app/api/auth/forgot-password/route.tsx +65 -0
  40. package/app/api/auth/reset-password/route.tsx +52 -0
  41. package/app/api/auth/verify-2fa/route.tsx +88 -0
  42. package/app/api/backup/download/db/route.ts +29 -0
  43. package/app/api/backup/download/notes/route.ts +25 -0
  44. package/app/api/backup/settings/route.ts +92 -0
  45. package/app/api/billing/checkout/route.tsx +81 -0
  46. package/app/api/billing/migrate/route.tsx +163 -0
  47. package/app/api/billing/portal/route.tsx +24 -0
  48. package/app/api/billing/setup-intent/route.tsx +55 -0
  49. package/app/api/billing/status/route.tsx +36 -0
  50. package/app/api/billing/subscribe/route.tsx +85 -0
  51. package/app/api/billing/webhook/route.tsx +199 -0
  52. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  53. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  54. package/app/api/calendar-feeds/events/route.tsx +82 -0
  55. package/app/api/calendar-feeds/route.tsx +52 -0
  56. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  57. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  58. package/app/api/cron/stale-tasks/route.tsx +51 -0
  59. package/app/api/cron/sync/route.tsx +34 -0
  60. package/app/api/devices/[deviceId]/route.tsx +25 -0
  61. package/app/api/devices/route.tsx +41 -0
  62. package/app/api/export/route.tsx +40 -0
  63. package/app/api/feedback/route.tsx +54 -0
  64. package/app/api/folders/[folderId]/route.tsx +51 -0
  65. package/app/api/folders/route.tsx +37 -0
  66. package/app/api/graph/route.tsx +242 -0
  67. package/app/api/guest/route.tsx +58 -0
  68. package/app/api/health/route.tsx +10 -0
  69. package/app/api/holidays/countries/route.tsx +14 -0
  70. package/app/api/holidays/route.tsx +49 -0
  71. package/app/api/holidays/states/route.tsx +21 -0
  72. package/app/api/invites/[token]/route.tsx +131 -0
  73. package/app/api/invites/route.tsx +74 -0
  74. package/app/api/mcp/generate-token/route.tsx +55 -0
  75. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  76. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  77. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  78. package/app/api/notes/[noteId]/route.tsx +360 -0
  79. package/app/api/notes/route.tsx +112 -0
  80. package/app/api/notifications/route.tsx +44 -0
  81. package/app/api/register/route.tsx +67 -0
  82. package/app/api/restore/route.tsx +148 -0
  83. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  84. package/app/api/sync/conflicts/route.tsx +48 -0
  85. package/app/api/sync/status/route.tsx +49 -0
  86. package/app/api/sync/trigger/route.tsx +15 -0
  87. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  88. package/app/api/tasks/[taskId]/route.tsx +259 -0
  89. package/app/api/tasks/bulk/route.tsx +133 -0
  90. package/app/api/tasks/route.tsx +36 -0
  91. package/app/api/workspace/active/route.tsx +39 -0
  92. package/app/api/workspace/create-team/route.tsx +42 -0
  93. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  94. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  95. package/app/api/workspace/route.tsx +24 -0
  96. package/app/download/page.tsx +170 -0
  97. package/app/favicon.ico +0 -0
  98. package/app/generated/prisma/client.d.ts +1 -0
  99. package/app/generated/prisma/client.js +5 -0
  100. package/app/generated/prisma/default.d.ts +1 -0
  101. package/app/generated/prisma/default.js +5 -0
  102. package/app/generated/prisma/edge.d.ts +1 -0
  103. package/app/generated/prisma/edge.js +497 -0
  104. package/app/generated/prisma/index-browser.js +523 -0
  105. package/app/generated/prisma/index.d.ts +46376 -0
  106. package/app/generated/prisma/index.js +497 -0
  107. package/app/generated/prisma/package.json +144 -0
  108. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  111. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  112. package/app/generated/prisma/runtime/client.js +86 -0
  113. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  114. package/app/generated/prisma/runtime/index-browser.js +6 -0
  115. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  116. package/app/generated/prisma/schema.prisma +456 -0
  117. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  118. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  119. package/app/globals.css +54 -0
  120. package/app/invite/[token]/page.tsx +52 -0
  121. package/app/layout.tsx +90 -0
  122. package/app/mcp/route.tsx +430 -0
  123. package/app/opengraph-image.tsx +120 -0
  124. package/app/page.tsx +398 -0
  125. package/app/privacy/page.tsx +69 -0
  126. package/app/robots.tsx +25 -0
  127. package/app/sitemap.tsx +36 -0
  128. package/app/terms/page.tsx +69 -0
  129. package/app/upgrade/page.tsx +75 -0
  130. package/auth.config.ts +33 -0
  131. package/auth.ts +79 -0
  132. package/bin/brief.js +224 -0
  133. package/components/auth/login-form.tsx +302 -0
  134. package/components/auth/password-checklist.tsx +31 -0
  135. package/components/auth/password-input.tsx +36 -0
  136. package/components/auth/switch-account-button.test.tsx +22 -0
  137. package/components/auth/switch-account-button.tsx +19 -0
  138. package/components/auth/two-factor-input.tsx +116 -0
  139. package/components/billing/billing-dashboard.tsx +265 -0
  140. package/components/billing/card-form.tsx +210 -0
  141. package/components/billing/claim-account-form.tsx +99 -0
  142. package/components/branding/app-logo.test.tsx +20 -0
  143. package/components/branding/app-logo.tsx +25 -0
  144. package/components/calendar/calendar-agenda.tsx +150 -0
  145. package/components/calendar/calendar-drag.test.tsx +177 -0
  146. package/components/calendar/calendar-grid.tsx +357 -0
  147. package/components/calendar/calendar-hooks.test.tsx +27 -0
  148. package/components/calendar/calendar-hooks.ts +351 -0
  149. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  150. package/components/calendar/calendar-toolbar.tsx +291 -0
  151. package/components/calendar/calendar-types.ts +148 -0
  152. package/components/calendar/calendar-view.test.tsx +295 -0
  153. package/components/calendar/calendar-view.tsx +307 -0
  154. package/components/calendar/day-detail-popover.tsx +174 -0
  155. package/components/calendar/task-chip.tsx +86 -0
  156. package/components/command/command-palette.test.tsx +33 -0
  157. package/components/command/command-palette.tsx +310 -0
  158. package/components/download-cta.tsx +87 -0
  159. package/components/feedback/feedback-popup.tsx +207 -0
  160. package/components/graph/graph-draw.ts +337 -0
  161. package/components/graph/graph-overlays.tsx +160 -0
  162. package/components/graph/graph-page.test.tsx +131 -0
  163. package/components/graph/graph-page.tsx +263 -0
  164. package/components/graph/graph-types.ts +47 -0
  165. package/components/graph/graph-view.tsx +322 -0
  166. package/components/guide/guide-view.tsx +522 -0
  167. package/components/kanban/kanban-board.test.tsx +128 -0
  168. package/components/kanban/kanban-board.tsx +361 -0
  169. package/components/kanban/kanban-card-menu.tsx +102 -0
  170. package/components/kanban/kanban-card.tsx +227 -0
  171. package/components/kanban/kanban-column.tsx +49 -0
  172. package/components/kanban/kanban-status-context.tsx +28 -0
  173. package/components/landing/calendar-sandbox.test.tsx +15 -0
  174. package/components/landing/calendar-sandbox.tsx +107 -0
  175. package/components/landing/graph-sandbox.test.tsx +27 -0
  176. package/components/landing/graph-sandbox.tsx +80 -0
  177. package/components/landing/kanban-sandbox.test.tsx +24 -0
  178. package/components/landing/kanban-sandbox.tsx +101 -0
  179. package/components/landing/landing-showcase.test.tsx +21 -0
  180. package/components/landing/landing-showcase.tsx +54 -0
  181. package/components/landing/list-sandbox.tsx +86 -0
  182. package/components/landing/mock-workspace.ts +168 -0
  183. package/components/landing/notes-sandbox.test.tsx +14 -0
  184. package/components/landing/notes-sandbox.tsx +88 -0
  185. package/components/layout/app-shell.tsx +83 -0
  186. package/components/layout/backup-scheduler.tsx +122 -0
  187. package/components/layout/bottom-nav.tsx +43 -0
  188. package/components/layout/icon-bar.test.tsx +29 -0
  189. package/components/layout/icon-bar.tsx +118 -0
  190. package/components/layout/mobile-top-bar.tsx +68 -0
  191. package/components/layout/notes-panel-folder.tsx +127 -0
  192. package/components/layout/notes-panel-note-item.tsx +140 -0
  193. package/components/layout/notes-panel-task-tab.tsx +63 -0
  194. package/components/layout/notes-panel-types.ts +44 -0
  195. package/components/layout/notes-panel.tsx +476 -0
  196. package/components/layout/notification-bell.tsx +251 -0
  197. package/components/layout/paywall-screen.tsx +41 -0
  198. package/components/layout/pro-banner.tsx +76 -0
  199. package/components/layout/sw-register.tsx +27 -0
  200. package/components/layout/workspace-switcher.tsx +90 -0
  201. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  202. package/components/notes/note-editor-context-menu.tsx +47 -0
  203. package/components/notes/note-editor-dom.ts +33 -0
  204. package/components/notes/note-editor-dropdowns.tsx +484 -0
  205. package/components/notes/note-editor-hooks.ts +692 -0
  206. package/components/notes/note-editor-keyboard.ts +305 -0
  207. package/components/notes/note-editor-overlay.tsx +90 -0
  208. package/components/notes/note-editor.test.tsx +372 -0
  209. package/components/notes/note-editor.tsx +662 -0
  210. package/components/notes/note-preview-pane.tsx +156 -0
  211. package/components/notes/note-tabs.tsx +120 -0
  212. package/components/notes/note-types.tsx +157 -0
  213. package/components/settings/accept-invite.tsx +108 -0
  214. package/components/settings/agent-token-settings.tsx +369 -0
  215. package/components/settings/backup-restore-settings.test.tsx +25 -0
  216. package/components/settings/backup-restore-settings.tsx +327 -0
  217. package/components/settings/calendar-feeds-settings.tsx +489 -0
  218. package/components/settings/calendar-general-settings.tsx +174 -0
  219. package/components/settings/confirm-danger-action.test.tsx +215 -0
  220. package/components/settings/confirm-danger-action.tsx +65 -0
  221. package/components/settings/security-settings.tsx +252 -0
  222. package/components/settings/settings-guidance.test.tsx +98 -0
  223. package/components/settings/team-settings.tsx +319 -0
  224. package/components/settings/two-factor-auth.tsx +296 -0
  225. package/components/settings/workspace-settings-client.tsx +363 -0
  226. package/components/settings/workspace-settings-form.tsx +73 -0
  227. package/components/sync/conflict-viewer.tsx +247 -0
  228. package/components/sync/sync-indicator.tsx +171 -0
  229. package/components/tasks/snippet-thread.tsx +119 -0
  230. package/components/tasks/status-dot.tsx +47 -0
  231. package/components/tasks/task-badge.tsx +43 -0
  232. package/components/tasks/task-detail.test.tsx +187 -0
  233. package/components/tasks/task-detail.tsx +458 -0
  234. package/components/tasks/task-list-filters.test.tsx +75 -0
  235. package/components/tasks/task-list-filters.tsx +163 -0
  236. package/components/tasks/task-list-types.ts +20 -0
  237. package/components/tasks/task-list.test.tsx +175 -0
  238. package/components/tasks/task-list.tsx +481 -0
  239. package/components/tasks/task-row.tsx +85 -0
  240. package/components/tasks/task-table-row.tsx +259 -0
  241. package/components/ui/skeleton.tsx +3 -0
  242. package/components/ui/toast.test.tsx +42 -0
  243. package/components/ui/toast.tsx +70 -0
  244. package/instrumentation.tsx +23 -0
  245. package/lib/api-error.ts +50 -0
  246. package/lib/backup/backup-runner.test.ts +32 -0
  247. package/lib/backup/backup-runner.ts +19 -0
  248. package/lib/backup/backup-schedule.test.ts +23 -0
  249. package/lib/backup/backup-schedule.ts +55 -0
  250. package/lib/backup/backup-settings.test.ts +30 -0
  251. package/lib/backup/backup-settings.ts +27 -0
  252. package/lib/backup/export-notes-zip.test.ts +26 -0
  253. package/lib/backup/export-notes-zip.ts +82 -0
  254. package/lib/backup/export-workspace-backup.test.ts +17 -0
  255. package/lib/backup/export-workspace-backup.ts +77 -0
  256. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  257. package/lib/backup/restore-workspace-from-export.ts +183 -0
  258. package/lib/backup/types.ts +14 -0
  259. package/lib/brand-icons.ts +1 -0
  260. package/lib/calendar-feed-crypto.ts +38 -0
  261. package/lib/calendar-feed.ts +239 -0
  262. package/lib/client/online-status.ts +47 -0
  263. package/lib/conflict-resolver.test.ts +57 -0
  264. package/lib/conflict-resolver.ts +240 -0
  265. package/lib/db-init.ts +79 -0
  266. package/lib/email.ts +159 -0
  267. package/lib/encryption.test.ts +41 -0
  268. package/lib/encryption.ts +98 -0
  269. package/lib/extract-snippet.test.ts +123 -0
  270. package/lib/extract-snippet.ts +69 -0
  271. package/lib/kanban-status.ts +55 -0
  272. package/lib/license.ts +21 -0
  273. package/lib/limits.ts +31 -0
  274. package/lib/mcp-auth.test.ts +58 -0
  275. package/lib/mcp-auth.ts +65 -0
  276. package/lib/mcp-contract.test.ts +25 -0
  277. package/lib/mcp-contract.ts +210 -0
  278. package/lib/mcp-handler.ts +31 -0
  279. package/lib/mcp-url.test.ts +12 -0
  280. package/lib/mcp-url.ts +7 -0
  281. package/lib/mentions.test.ts +45 -0
  282. package/lib/mentions.ts +73 -0
  283. package/lib/note-crypto.ts +108 -0
  284. package/lib/note-sync.ts +201 -0
  285. package/lib/note-title.ts +93 -0
  286. package/lib/prisma.ts +193 -0
  287. package/lib/pro-flush.ts +292 -0
  288. package/lib/rate-limit.ts +57 -0
  289. package/lib/stripe.ts +38 -0
  290. package/lib/sync-worker.ts +388 -0
  291. package/lib/task-parser.test.ts +91 -0
  292. package/lib/task-parser.ts +81 -0
  293. package/lib/task-utils.ts +52 -0
  294. package/lib/use-is-electron.ts +19 -0
  295. package/lib/use-is-mobile.ts +22 -0
  296. package/lib/validation/calendar-feed.ts +31 -0
  297. package/lib/validation/note.ts +27 -0
  298. package/lib/validation/task.ts +26 -0
  299. package/lib/view-preferences.test.ts +54 -0
  300. package/lib/view-preferences.ts +28 -0
  301. package/lib/workspace.ts +66 -0
  302. package/next.config.ts +21 -0
  303. package/package.json +49 -3
  304. package/postcss.config.mjs +7 -0
  305. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  306. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  307. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  308. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  309. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  310. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  311. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  312. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  313. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  314. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  315. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  316. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  317. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  318. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  319. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  320. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  321. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  322. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  323. package/prisma/migrations/migration_lock.toml +3 -0
  324. package/prisma/schema.prisma +457 -0
  325. package/public/Logo_icon.svg +1 -0
  326. package/public/file.svg +1 -0
  327. package/public/globe.svg +1 -0
  328. package/public/icon-192.png +0 -0
  329. package/public/icon-512.png +0 -0
  330. package/public/icon.svg +4 -0
  331. package/public/icon_dark.svg +1 -0
  332. package/public/knotpad_icon.svg +1 -0
  333. package/public/knotpad_logo_full.svg +1 -0
  334. package/public/manifest.json +14 -0
  335. package/public/next.svg +1 -0
  336. package/public/sw.js +137 -0
  337. package/public/vercel.svg +1 -0
  338. package/public/window.svg +1 -0
  339. package/tsconfig.json +35 -0
  340. package/brief.js +0 -311
@@ -0,0 +1,65 @@
1
+ import { SignJWT, jwtVerify } from "jose";
2
+ import { prisma } from "@/lib/prisma";
3
+
4
+ // Throttle lastUsed updates to reduce DB row contention (heartbeat calls ~120/min)
5
+ const THROTTLE_MS = 60_000;
6
+ const lastUsedCache = new Map<string, number>();
7
+
8
+ const MCP_SECRET = new TextEncoder().encode(
9
+ process.env.MCP_SECRET ?? "dev-mcp-secret-change-in-production"
10
+ );
11
+
12
+ export type McpTokenPayload = {
13
+ userId: string;
14
+ workspaceId: string;
15
+ tokenId: string;
16
+ };
17
+
18
+ export async function signMcpToken(payload: McpTokenPayload): Promise<string> {
19
+ return new SignJWT({ ...payload })
20
+ .setProtectedHeader({ alg: "HS256" })
21
+ .setIssuedAt()
22
+ .setExpirationTime("1y")
23
+ .sign(MCP_SECRET);
24
+ }
25
+
26
+ export async function verifyMcpToken(
27
+ token: string
28
+ ): Promise<(McpTokenPayload & { alias: string | null }) | null> {
29
+ try {
30
+ const { payload } = await jwtVerify(token, MCP_SECRET);
31
+ const { userId, workspaceId, tokenId } = payload as McpTokenPayload;
32
+
33
+ // Check DB: not revoked, correct IDs
34
+ const record = await prisma.mcpToken.findUnique({ where: { id: tokenId } });
35
+ if (
36
+ !record ||
37
+ record.revokedAt ||
38
+ record.userId !== userId ||
39
+ record.workspaceId !== workspaceId
40
+ ) {
41
+ return null;
42
+ }
43
+
44
+ // Touch lastUsed (throttled to reduce DB row contention)
45
+ const now = Date.now();
46
+ const lastUpdate = lastUsedCache.get(tokenId) ?? 0;
47
+ if (now - lastUpdate > THROTTLE_MS) {
48
+ lastUsedCache.set(tokenId, now);
49
+ // Fire-and-forget: don't await so it never blocks auth
50
+ prisma.mcpToken.update({
51
+ where: { id: tokenId },
52
+ data: { lastUsed: new Date() },
53
+ }).catch(() => {});
54
+ }
55
+
56
+ return { userId, workspaceId, tokenId, alias: record.alias };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function extractBearerToken(authHeader: string | null): string | null {
63
+ if (!authHeader?.startsWith("Bearer ")) return null;
64
+ return authHeader.slice(7);
65
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SERVER_INSTRUCTIONS, TOOLS } from "@/lib/mcp-contract";
3
+
4
+ describe("MCP contract", () => {
5
+ it("documents markdown equivalents for editor shortcuts", () => {
6
+ expect(SERVER_INSTRUCTIONS).toContain("/todo");
7
+ expect(SERVER_INSTRUCTIONS).toContain("[[Note Title]]");
8
+ expect(SERVER_INSTRUCTIONS).toContain("((Task Title))");
9
+ expect(SERVER_INSTRUCTIONS).toContain("!high");
10
+ });
11
+
12
+ it("teaches create_note how to write agent-friendly markdown", () => {
13
+ const createNote = TOOLS.find((tool) => tool.name === "create_note");
14
+
15
+ expect(createNote?.description).toContain("editor shortcuts");
16
+ expect(JSON.stringify(createNote?.inputSchema)).toContain("- [ ]");
17
+ });
18
+
19
+ it("teaches append_to_note how to append task-aware markdown", () => {
20
+ const appendToNote = TOOLS.find((tool) => tool.name === "append_to_note");
21
+
22
+ expect(appendToNote?.description).toContain("append markdown");
23
+ expect(JSON.stringify(appendToNote?.inputSchema)).toContain("!high");
24
+ });
25
+ });
@@ -0,0 +1,210 @@
1
+ type McpToolSchema = {
2
+ type: "object";
3
+ properties: Record<string, unknown>;
4
+ required: string[];
5
+ };
6
+
7
+ export type McpTool = {
8
+ name: string;
9
+ description: string;
10
+ inputSchema: McpToolSchema;
11
+ };
12
+
13
+ export const TOOLS: McpTool[] = [
14
+ {
15
+ name: "get_notes",
16
+ description:
17
+ "List all notes in the workspace. Returns note IDs, titles, and task counts. " +
18
+ "Use this first to discover what work is tracked. Note IDs are needed for get_note and append_to_note.",
19
+ inputSchema: { type: "object", properties: {}, required: [] },
20
+ },
21
+ {
22
+ name: "get_tasks",
23
+ description:
24
+ "List unclaimed agent tasks (assigned to @agent, status OPEN, not yet claimed). " +
25
+ "Returns task IDs, titles, source notes, and age. " +
26
+ "Use this to find work items you can pick up. Always claim a task before starting work on it.",
27
+ inputSchema: { type: "object", properties: {}, required: [] },
28
+ },
29
+ {
30
+ name: "get_task_detail",
31
+ description:
32
+ "Get full details of a specific task including its audit log history. " +
33
+ "Use this to understand a task's context, past status changes, and who interacted with it.",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: { taskId: { type: "string", description: "The task ID (from get_tasks or get_notes)" } },
37
+ required: ["taskId"],
38
+ },
39
+ },
40
+ {
41
+ name: "get_linked_files",
42
+ description:
43
+ "Get file path references linked to a task (e.g. source files the task relates to). " +
44
+ "Use this to know which files to look at when working on a task.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: { taskId: { type: "string", description: "The task ID" } },
48
+ required: ["taskId"],
49
+ },
50
+ },
51
+ {
52
+ name: "claim_tasks",
53
+ description:
54
+ "Claim one or more agent tasks to reserve them for yourself. " +
55
+ "You MUST claim a task before working on it — this prevents other agents from picking it up. " +
56
+ "After claiming, send periodic heartbeats and update the status as you make progress.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ ids: { type: "array", items: { type: "string" }, description: "Task IDs to claim (from get_tasks)" },
61
+ },
62
+ required: ["ids"],
63
+ },
64
+ },
65
+ {
66
+ name: "release_task",
67
+ description:
68
+ "Release a task you previously claimed back to the open queue. " +
69
+ "Use this if you cannot complete the task, it's blocked, or it's no longer relevant. " +
70
+ "Provide a reason so the workspace owner understands why it was released. " +
71
+ "This notifies the workspace owner.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ id: { type: "string", description: "The task ID to release" },
76
+ reason: { type: "string", description: "Why you are releasing it (optional but recommended)" },
77
+ },
78
+ required: ["id"],
79
+ },
80
+ },
81
+ {
82
+ name: "update_task",
83
+ description:
84
+ "Update the status of a task you have claimed. Status flow: in_progress → review → done. " +
85
+ "Use 'in_progress' when you start working, 'review' when work is complete and awaiting approval, " +
86
+ "'done' when fully finished (this also auto-checks the task off in the source note). " +
87
+ "Each update resets the heartbeat timer.",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ id: { type: "string", description: "The task ID" },
92
+ status: {
93
+ type: "string",
94
+ enum: ["in_progress", "review", "done"],
95
+ description: "New status: in_progress (working), review (awaiting approval), done (completed)",
96
+ },
97
+ },
98
+ required: ["id", "status"],
99
+ },
100
+ },
101
+ {
102
+ name: "heartbeat",
103
+ description:
104
+ "Send a heartbeat to signal you are still actively working on a claimed task. " +
105
+ "Send heartbeats regularly (e.g. every few minutes) while working on long tasks " +
106
+ "so the system knows the task hasn't been abandoned.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: { id: { type: "string", description: "The task ID" } },
110
+ required: ["id"],
111
+ },
112
+ },
113
+ {
114
+ name: "create_note",
115
+ description:
116
+ "Create a new note in the workspace with markdown content. Use markdown, not literal editor shortcuts: " +
117
+ "/todo becomes '- [ ] Task title', + note becomes '[[Note Title]]', + task becomes '((Task Title))', " +
118
+ "+ mention becomes '@agent' or '@human(name)', and + priority becomes '!high' or another !priority tag. " +
119
+ "Checkbox task lines automatically create tasks and parse priority and ISO date metadata.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ title: { type: "string", description: "Note title (e.g. 'Sprint 24 Tasks')" },
124
+ content: {
125
+ type: "string",
126
+ description:
127
+ "Markdown content using the parser's real syntax. Example task: '- [ ] Ship MCP docs @agent !high " +
128
+ "2026-06-14..2026-06-20 <app/mcp/route.tsx>'. Use '- [x]' for completed tasks, '[[Note Title]]' " +
129
+ "for note links, '((Task Title))' for task references, and @human(name) for human-assigned tasks.",
130
+ },
131
+ },
132
+ required: ["title"],
133
+ },
134
+ },
135
+ {
136
+ name: "get_note",
137
+ description:
138
+ "Get a note's full decrypted markdown content along with all its tasks. " +
139
+ "Use this to read the complete context of a task or to see all work tracked in a note.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: { noteId: { type: "string", description: "The note ID (from get_notes)" } },
143
+ required: ["noteId"],
144
+ },
145
+ },
146
+ {
147
+ name: "append_to_note",
148
+ description:
149
+ "Append markdown content to an existing note. Use append markdown with the same note-writing syntax as create_note: " +
150
+ "checkbox tasks, note links, task references, mentions, !priority tags, and ISO dates are all supported. " +
151
+ "New task lines automatically create tasks without overwriting existing content. Cannot be used on locked notes.",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ noteId: { type: "string", description: "The note ID (from get_notes)" },
156
+ content: {
157
+ type: "string",
158
+ description:
159
+ "Markdown to append, such as '- [ ] Review release notes @agent !high 2026-06-14 <docs/release.md>' " +
160
+ "or 'Linked note [[Release Plan]] with task ref ((Review release notes))'.",
161
+ },
162
+ },
163
+ required: ["noteId", "content"],
164
+ },
165
+ },
166
+ ];
167
+
168
+ export const SERVER_INSTRUCTIONS = [
169
+ "You are connected to Knotpad, a task and note management workspace for dev teams.",
170
+ "",
171
+ "## Core Concepts",
172
+ "- **Notes** contain markdown with embedded task checkboxes.",
173
+ "- **Tasks** are parsed from notes and can be assigned to @agent (AI) or @human.",
174
+ "- Agent tasks must be **claimed** before working on them to avoid conflicts.",
175
+ "- Tasks have a lifecycle: OPEN → CLAIMED → IN_PROGRESS → REVIEW → DONE.",
176
+ "",
177
+ "## Recommended Agent Workflow",
178
+ "1. Call `get_notes` to see all notes and identify work areas.",
179
+ "2. Call `get_tasks` to see unclaimed agent tasks available to you.",
180
+ "3. Call `get_task_detail` and `get_linked_files` to understand a task's context and related files.",
181
+ "4. Call `claim_tasks` to reserve the tasks you want to work on.",
182
+ "5. Call `update_task` with status 'in_progress' when you start.",
183
+ "6. Send `heartbeat` periodically while working on long tasks.",
184
+ "7. When work is done, call `update_task` with status 'review' or 'done'.",
185
+ "8. Use `create_note` or `append_to_note` to document your work or create new task plans.",
186
+ "",
187
+ "## Writing Notes Correctly",
188
+ "- Prefer valid markdown content over UI-specific wording like 'press /todo'.",
189
+ "- `/todo` in the editor becomes `- [ ] Task title` in note content.",
190
+ "- `/bullet` in the editor becomes `- item` in note content.",
191
+ "- `+ note` becomes `[[Note Title]]`.",
192
+ "- `+ task` becomes `((Task Title))` for referencing an existing task.",
193
+ "- `+ mention` becomes `@agent` or `@human(name)`.",
194
+ "- `+ priority` becomes `!critical`, `!high`, `!medium`, or `!low`.",
195
+ "- Supported date syntax is `YYYY-MM-DD` or `YYYY-MM-DD..YYYY-MM-DD`.",
196
+ "",
197
+ "## Task Syntax in Notes",
198
+ "- Agent task: `- [ ] Fix login bug @agent <src/auth/login.ts> !high 2026-06-14`",
199
+ "- Completed task: `- [x] Ship MCP docs @agent !high 2026-06-14..2026-06-20 <app/mcp/route.tsx>`",
200
+ "- Human task: `- [ ] Review design @human(alice)`",
201
+ "- Note link: `[[Sprint Plan]]`",
202
+ "- Task reference: `((Fix login bug))` anywhere in note text.",
203
+ "",
204
+ "## Important Rules",
205
+ "- Always claim a task before starting work on it.",
206
+ "- Send heartbeats every few minutes for long-running tasks.",
207
+ "- Release tasks you cannot complete, with a reason.",
208
+ "- Only update status on tasks you have claimed.",
209
+ "- When marking a task 'done', the source note checkbox is automatically checked.",
210
+ ].join("\n");
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { verifyMcpToken, extractBearerToken, McpTokenPayload } from "@/lib/mcp-auth";
3
+ import { rateLimit, getClientIp } from "@/lib/rate-limit";
4
+
5
+ export type McpContext = McpTokenPayload & { alias: string | null };
6
+
7
+ // 120 requests per minute per IP — generous for agent polling, tight enough to block abuse
8
+ const MCP_RATE_LIMIT_MAX = 120;
9
+ const MCP_RATE_LIMIT_WINDOW_MS = 60_000;
10
+
11
+ export async function withMcpAuth(
12
+ req: NextRequest,
13
+ handler: (ctx: McpContext) => Promise<NextResponse>
14
+ ): Promise<NextResponse> {
15
+ const ip = getClientIp(req);
16
+ const rl = rateLimit(`mcp:${ip}`, MCP_RATE_LIMIT_MAX, MCP_RATE_LIMIT_WINDOW_MS);
17
+ if (rl.limited) {
18
+ return NextResponse.json(
19
+ { error: "Too many requests" },
20
+ { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }
21
+ );
22
+ }
23
+
24
+ const token = extractBearerToken(req.headers.get("authorization"));
25
+ if (!token) return NextResponse.json({ error: "Missing token" }, { status: 401 });
26
+
27
+ const ctx = await verifyMcpToken(token);
28
+ if (!ctx) return NextResponse.json({ error: "Invalid or revoked token" }, { status: 401 });
29
+
30
+ return handler(ctx);
31
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getMcpUrl } from "@/lib/mcp-url";
3
+
4
+ describe("getMcpUrl", () => {
5
+ it("prefers the explicit MCP URL", () => {
6
+ expect(getMcpUrl("https://app.example.com", "https://mcp.example.com")).toBe("https://mcp.example.com");
7
+ });
8
+
9
+ it("derives the MCP URL from the app URL when no explicit value is set", () => {
10
+ expect(getMcpUrl("http://localhost:3000", undefined)).toBe("http://localhost:3000/mcp");
11
+ });
12
+ });
package/lib/mcp-url.ts ADDED
@@ -0,0 +1,7 @@
1
+ export function getMcpUrl(appUrl: string, explicitMcpUrl?: string): string {
2
+ if (explicitMcpUrl) {
3
+ return explicitMcpUrl;
4
+ }
5
+
6
+ return `${appUrl.replace(/\/$/, "")}/mcp`;
7
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveAssignee, normalizeHandle } from "@/lib/mentions";
3
+
4
+ // Minimal fake tx exposing only what resolveAssignee touches.
5
+ function fakeTx(members: Array<{ userId: string; user: { name: string | null; email: string | null } }>) {
6
+ return { workspaceMember: { findMany: async () => members } } as never;
7
+ }
8
+
9
+ const members = [
10
+ { userId: "u1", user: { name: "John Doe", email: "john@acme.com" } },
11
+ { userId: "u2", user: { name: null, email: "alice@acme.com" } },
12
+ ];
13
+
14
+ describe("resolveAssignee", () => {
15
+ it("matches a name handle even though the editor strips spaces", async () => {
16
+ // editor builds "@johndoe" from "John Doe"
17
+ const r = await resolveAssignee(fakeTx(members), "ws", "@johndoe");
18
+ expect(r).toEqual({ assigneeId: "u1", assigneeType: "HUMAN" });
19
+ });
20
+
21
+ it("matches a bare email local-part", async () => {
22
+ const r = await resolveAssignee(fakeTx(members), "ws", "@alice");
23
+ expect(r.assigneeId).toBe("u2");
24
+ });
25
+
26
+ it("does not ambiguously prefix-match emails", async () => {
27
+ // "@al" must NOT resolve to alice@ (old startsWith bug)
28
+ const r = await resolveAssignee(fakeTx(members), "ws", "@al");
29
+ expect(r.assigneeId).toBeNull();
30
+ });
31
+
32
+ it("treats @agent as an agent, not a human", async () => {
33
+ const r = await resolveAssignee(fakeTx(members), "ws", "@agent");
34
+ expect(r).toEqual({ assigneeId: null, assigneeType: "AGENT" });
35
+ });
36
+
37
+ it("returns no assignee for a null handle", async () => {
38
+ const r = await resolveAssignee(fakeTx(members), "ws", null);
39
+ expect(r).toEqual({ assigneeId: null, assigneeType: "HUMAN" });
40
+ });
41
+
42
+ it("normalizeHandle strips @, case, and whitespace", () => {
43
+ expect(normalizeHandle("@John Doe")).toBe("johndoe");
44
+ });
45
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Mention handle resolution — shared by every note→task sync path so the
3
+ * matching rules can't drift between call sites.
4
+ *
5
+ * The editor builds handles from a member's name (preferred) or email:
6
+ * @${(name ?? email).toLowerCase().replace(/\s+/g, "")}
7
+ * so resolution must normalise both sides identically. We also accept a bare
8
+ * email local-part (e.g. "@alice" for alice@acme.com) since people type that.
9
+ */
10
+
11
+ import { prisma } from "@/lib/prisma";
12
+ import { isAgentHandle } from "@/lib/task-parser";
13
+
14
+ type Tx = Parameters<Parameters<typeof prisma.$transaction>[0]>[0];
15
+
16
+ export type ResolvedAssignee = {
17
+ assigneeId: string | null;
18
+ assigneeType: "HUMAN" | "AGENT";
19
+ };
20
+
21
+ export type WorkspaceMemberWithUser = {
22
+ userId: string;
23
+ user: { id: string; name: string | null; email: string | null };
24
+ };
25
+
26
+ /** Strip a leading "@", lower-case, and remove whitespace. */
27
+ export function normalizeHandle(handle: string): string {
28
+ return handle.replace(/^@/, "").toLowerCase().replace(/\s+/g, "");
29
+ }
30
+
31
+ /**
32
+ * Fetch all workspace members once so callers in a loop can pass the list in
33
+ * and avoid an N+1 per-task query.
34
+ */
35
+ export async function fetchWorkspaceMembers(
36
+ tx: Tx,
37
+ workspaceId: string
38
+ ): Promise<WorkspaceMemberWithUser[]> {
39
+ return tx.workspaceMember.findMany({
40
+ where: { workspaceId },
41
+ include: { user: { select: { id: true, name: true, email: true } } },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Resolve an @mention handle to a workspace user.
47
+ * Pass `prefetchedMembers` (from `fetchWorkspaceMembers`) to avoid a DB query
48
+ * per call when resolving multiple tasks in a loop.
49
+ */
50
+ export async function resolveAssignee(
51
+ tx: Tx,
52
+ workspaceId: string,
53
+ handle: string | null,
54
+ prefetchedMembers?: WorkspaceMemberWithUser[]
55
+ ): Promise<ResolvedAssignee> {
56
+ if (!handle) return { assigneeId: null, assigneeType: "HUMAN" };
57
+ if (isAgentHandle(handle)) return { assigneeId: null, assigneeType: "AGENT" };
58
+
59
+ const slug = normalizeHandle(handle);
60
+ const members = prefetchedMembers ?? await tx.workspaceMember.findMany({
61
+ where: { workspaceId },
62
+ include: { user: { select: { id: true, name: true, email: true } } },
63
+ });
64
+
65
+ const match = members.find((m) => {
66
+ const email = (m.user.email ?? "").toLowerCase().replace(/\s+/g, "");
67
+ const nameSlug = (m.user.name ?? "").toLowerCase().replace(/\s+/g, "");
68
+ const emailLocal = email.split("@")[0];
69
+ return slug === nameSlug || slug === email || slug === emailLocal;
70
+ });
71
+
72
+ return { assigneeId: match?.userId ?? null, assigneeType: "HUMAN" };
73
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Transparent note encryption-at-rest layer — server-side AES-256-GCM.
3
+ *
4
+ * Encryption policy:
5
+ * - Local-only mode (IS_CLOUD=false, no CLOUD_DATABASE_URL):
6
+ * Notes are stored as plaintext in PGlite. The data never leaves the
7
+ * user's device, so encryption is unnecessary and would only create
8
+ * a footgun (lose ENCRYPTION_PEPPER → lose all notes).
9
+ *
10
+ * - Cloud mode (IS_CLOUD=true) or local with cloud sync (CLOUD_DATABASE_URL):
11
+ * Notes are always encrypted before storage. This protects data at rest
12
+ * in the cloud DB (Neon). The server holds the key (ENCRYPTION_PEPPER).
13
+ *
14
+ * Two sets of helpers, kept for call-site clarity:
15
+ * encryptContent / decryptContent → API routes / MCP (primary `prisma`)
16
+ * encryptForSync / decryptFromSync → sync-worker talking to either DB
17
+ * Both behave identically. Decryption is safe to call on plaintext (returns
18
+ * it unchanged) so legacy rows and local-only data don't throw.
19
+ *
20
+ * Only note.content is encrypted. Titles, ids, timestamps, and task data stay
21
+ * plaintext so they remain queryable.
22
+ */
23
+
24
+ import { prisma, getCloudPrisma } from "@/lib/prisma";
25
+ import { encryptNote, decryptNote, generateSalt, isEncrypted } from "@/lib/encryption";
26
+
27
+ // ── Cloud check ─────────────────────────────────────────────────────────────
28
+ // Encryption is only needed when data may reach the cloud DB. In local-only
29
+ // mode (no cloud sync), notes stay plaintext on the user's own device.
30
+ function isCloudEnabled(): boolean {
31
+ return process.env.IS_CLOUD === "true" || !!process.env.CLOUD_DATABASE_URL;
32
+ }
33
+
34
+ // ── Salt cache ────────────────────────────────────────────────────────────────
35
+ // Per-workspace salt persisted in the workspace row. Cached in-process to avoid
36
+ // an extra round-trip on every note read/write.
37
+
38
+ const saltCache = new Map<string, string>();
39
+
40
+ async function getWorkspaceSalt(workspaceId: string): Promise<string> {
41
+ const cached = saltCache.get(workspaceId);
42
+ if (cached) return cached;
43
+
44
+ const workspace = await prisma.workspace.findUnique({
45
+ where: { id: workspaceId },
46
+ select: { encryptionSalt: true },
47
+ });
48
+
49
+ if (workspace?.encryptionSalt) {
50
+ saltCache.set(workspaceId, workspace.encryptionSalt);
51
+ return workspace.encryptionSalt;
52
+ }
53
+
54
+ // First time for this workspace: generate and persist the salt.
55
+ const salt = generateSalt();
56
+ await prisma.workspace.update({
57
+ where: { id: workspaceId },
58
+ data: { encryptionSalt: salt },
59
+ });
60
+ // Mirror to Neon so the migration step can find the salt alongside the workspace record.
61
+ // Non-fatal: if the cloud write fails, the migration endpoint will copy the workspace row.
62
+ const cloud = getCloudPrisma();
63
+ if (cloud) {
64
+ cloud.workspace.update({ where: { id: workspaceId }, data: { encryptionSalt: salt } }).catch(() => {});
65
+ }
66
+ saltCache.set(workspaceId, salt);
67
+ return salt;
68
+ }
69
+
70
+ function getPepper(): string {
71
+ const pepper = process.env.ENCRYPTION_PEPPER;
72
+ if (!pepper) {
73
+ throw new Error(
74
+ "[Brief] ENCRYPTION_PEPPER env var is required to encrypt notes at rest. " +
75
+ "Generate one with: openssl rand -base64 32"
76
+ );
77
+ }
78
+ return pepper;
79
+ }
80
+
81
+ // ── Public helpers ──────────────────────────────────────────────────────────
82
+
83
+ export async function encryptContent(
84
+ content: string,
85
+ workspaceId: string
86
+ ): Promise<string> {
87
+ if (!isCloudEnabled()) return content; // local-only: store plaintext
88
+ if (isEncrypted(content)) return content; // never double-encrypt
89
+ const salt = await getWorkspaceSalt(workspaceId);
90
+ return encryptNote(content, getPepper(), salt);
91
+ }
92
+
93
+ export async function decryptContent(
94
+ content: string,
95
+ workspaceId: string
96
+ ): Promise<string> {
97
+ if (!isEncrypted(content)) return content; // plaintext or local-only safety
98
+ const salt = await getWorkspaceSalt(workspaceId);
99
+ return decryptNote(content, getPepper(), salt);
100
+ }
101
+
102
+ // Sync-worker aliases — identical behaviour, named for call-site intent.
103
+ export const encryptForSync = encryptContent;
104
+ export const decryptFromSync = decryptContent;
105
+
106
+ export function invalidateSaltCache(workspaceId: string): void {
107
+ saltCache.delete(workspaceId);
108
+ }