brains-mcp 1.61.2

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 (350) hide show
  1. package/README.md +95 -0
  2. package/dist/access/projectShares.d.ts +48 -0
  3. package/dist/access/projectShares.d.ts.map +1 -0
  4. package/dist/access/projectShares.js +203 -0
  5. package/dist/access/projectShares.js.map +1 -0
  6. package/dist/access/sharedInstructionsNote.d.ts +37 -0
  7. package/dist/access/sharedInstructionsNote.d.ts.map +1 -0
  8. package/dist/access/sharedInstructionsNote.js +76 -0
  9. package/dist/access/sharedInstructionsNote.js.map +1 -0
  10. package/dist/admin/seedUsers.d.ts +23 -0
  11. package/dist/admin/seedUsers.d.ts.map +1 -0
  12. package/dist/admin/seedUsers.js +168 -0
  13. package/dist/admin/seedUsers.js.map +1 -0
  14. package/dist/api/openapi.d.ts +2 -0
  15. package/dist/api/openapi.d.ts.map +1 -0
  16. package/dist/api/openapi.js +942 -0
  17. package/dist/api/openapi.js.map +1 -0
  18. package/dist/auth/clientStore.d.ts +43 -0
  19. package/dist/auth/clientStore.d.ts.map +1 -0
  20. package/dist/auth/clientStore.js +168 -0
  21. package/dist/auth/clientStore.js.map +1 -0
  22. package/dist/auth/deleteAccount.d.ts +34 -0
  23. package/dist/auth/deleteAccount.d.ts.map +1 -0
  24. package/dist/auth/deleteAccount.js +203 -0
  25. package/dist/auth/deleteAccount.js.map +1 -0
  26. package/dist/auth/oauth.d.ts +118 -0
  27. package/dist/auth/oauth.d.ts.map +1 -0
  28. package/dist/auth/oauth.js +491 -0
  29. package/dist/auth/oauth.js.map +1 -0
  30. package/dist/auth/patStore.d.ts +87 -0
  31. package/dist/auth/patStore.d.ts.map +1 -0
  32. package/dist/auth/patStore.js +308 -0
  33. package/dist/auth/patStore.js.map +1 -0
  34. package/dist/auth/supabase.d.ts +15 -0
  35. package/dist/auth/supabase.d.ts.map +1 -0
  36. package/dist/auth/supabase.js +66 -0
  37. package/dist/auth/supabase.js.map +1 -0
  38. package/dist/auth/tokenStore.d.ts +73 -0
  39. package/dist/auth/tokenStore.d.ts.map +1 -0
  40. package/dist/auth/tokenStore.js +261 -0
  41. package/dist/auth/tokenStore.js.map +1 -0
  42. package/dist/backup/drive.d.ts +24 -0
  43. package/dist/backup/drive.d.ts.map +1 -0
  44. package/dist/backup/drive.js +70 -0
  45. package/dist/backup/drive.js.map +1 -0
  46. package/dist/billing/tierStore.d.ts +59 -0
  47. package/dist/billing/tierStore.d.ts.map +1 -0
  48. package/dist/billing/tierStore.js +152 -0
  49. package/dist/billing/tierStore.js.map +1 -0
  50. package/dist/digest/activityDigest.d.ts +32 -0
  51. package/dist/digest/activityDigest.d.ts.map +1 -0
  52. package/dist/digest/activityDigest.js +299 -0
  53. package/dist/digest/activityDigest.js.map +1 -0
  54. package/dist/digest/digestCron.d.ts +40 -0
  55. package/dist/digest/digestCron.d.ts.map +1 -0
  56. package/dist/digest/digestCron.js +146 -0
  57. package/dist/digest/digestCron.js.map +1 -0
  58. package/dist/digest/digestPreferences.d.ts +25 -0
  59. package/dist/digest/digestPreferences.d.ts.map +1 -0
  60. package/dist/digest/digestPreferences.js +79 -0
  61. package/dist/digest/digestPreferences.js.map +1 -0
  62. package/dist/digest/emailSender.d.ts +20 -0
  63. package/dist/digest/emailSender.d.ts.map +1 -0
  64. package/dist/digest/emailSender.js +35 -0
  65. package/dist/digest/emailSender.js.map +1 -0
  66. package/dist/drive/client.d.ts +43 -0
  67. package/dist/drive/client.d.ts.map +1 -0
  68. package/dist/drive/client.js +282 -0
  69. package/dist/drive/client.js.map +1 -0
  70. package/dist/drive/errors.d.ts +27 -0
  71. package/dist/drive/errors.d.ts.map +1 -0
  72. package/dist/drive/errors.js +51 -0
  73. package/dist/drive/errors.js.map +1 -0
  74. package/dist/drive/fileCache.d.ts +22 -0
  75. package/dist/drive/fileCache.d.ts.map +1 -0
  76. package/dist/drive/fileCache.js +57 -0
  77. package/dist/drive/fileCache.js.map +1 -0
  78. package/dist/governance/accessControl.d.ts +87 -0
  79. package/dist/governance/accessControl.d.ts.map +1 -0
  80. package/dist/governance/accessControl.js +179 -0
  81. package/dist/governance/accessControl.js.map +1 -0
  82. package/dist/html/components.d.ts +8 -0
  83. package/dist/html/components.d.ts.map +1 -0
  84. package/dist/html/components.js +19 -0
  85. package/dist/html/components.js.map +1 -0
  86. package/dist/html/layout.d.ts +7 -0
  87. package/dist/html/layout.d.ts.map +1 -0
  88. package/dist/html/layout.js +24 -0
  89. package/dist/html/layout.js.map +1 -0
  90. package/dist/html/metricsUi.d.ts +8 -0
  91. package/dist/html/metricsUi.d.ts.map +1 -0
  92. package/dist/html/metricsUi.js +271 -0
  93. package/dist/html/metricsUi.js.map +1 -0
  94. package/dist/httpServer.d.ts +19 -0
  95. package/dist/httpServer.d.ts.map +1 -0
  96. package/dist/httpServer.js +3913 -0
  97. package/dist/httpServer.js.map +1 -0
  98. package/dist/index.d.ts +3 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +40 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/jobs/asyncJobRegistry.d.ts +30 -0
  103. package/dist/jobs/asyncJobRegistry.d.ts.map +1 -0
  104. package/dist/jobs/asyncJobRegistry.js +70 -0
  105. package/dist/jobs/asyncJobRegistry.js.map +1 -0
  106. package/dist/observability/metricsQuery.d.ts +41 -0
  107. package/dist/observability/metricsQuery.d.ts.map +1 -0
  108. package/dist/observability/metricsQuery.js +106 -0
  109. package/dist/observability/metricsQuery.js.map +1 -0
  110. package/dist/observability/relevanceSignal.d.ts +26 -0
  111. package/dist/observability/relevanceSignal.d.ts.map +1 -0
  112. package/dist/observability/relevanceSignal.js +95 -0
  113. package/dist/observability/relevanceSignal.js.map +1 -0
  114. package/dist/observability/requestEvents.d.ts +31 -0
  115. package/dist/observability/requestEvents.d.ts.map +1 -0
  116. package/dist/observability/requestEvents.js +54 -0
  117. package/dist/observability/requestEvents.js.map +1 -0
  118. package/dist/observability/slowQueryAlert.d.ts +25 -0
  119. package/dist/observability/slowQueryAlert.d.ts.map +1 -0
  120. package/dist/observability/slowQueryAlert.js +67 -0
  121. package/dist/observability/slowQueryAlert.js.map +1 -0
  122. package/dist/observability/zeroResultTriage.d.ts +22 -0
  123. package/dist/observability/zeroResultTriage.d.ts.map +1 -0
  124. package/dist/observability/zeroResultTriage.js +116 -0
  125. package/dist/observability/zeroResultTriage.js.map +1 -0
  126. package/dist/search/embeddings.d.ts +31 -0
  127. package/dist/search/embeddings.d.ts.map +1 -0
  128. package/dist/search/embeddings.js +91 -0
  129. package/dist/search/embeddings.js.map +1 -0
  130. package/dist/search/fileWatcher.d.ts +28 -0
  131. package/dist/search/fileWatcher.d.ts.map +1 -0
  132. package/dist/search/fileWatcher.js +103 -0
  133. package/dist/search/fileWatcher.js.map +1 -0
  134. package/dist/search/hotQueryCache.d.ts +31 -0
  135. package/dist/search/hotQueryCache.d.ts.map +1 -0
  136. package/dist/search/hotQueryCache.js +103 -0
  137. package/dist/search/hotQueryCache.js.map +1 -0
  138. package/dist/search/postgresIndex.d.ts +439 -0
  139. package/dist/search/postgresIndex.d.ts.map +1 -0
  140. package/dist/search/postgresIndex.js +1610 -0
  141. package/dist/search/postgresIndex.js.map +1 -0
  142. package/dist/search/sqliteIndex.d.ts +66 -0
  143. package/dist/search/sqliteIndex.d.ts.map +1 -0
  144. package/dist/search/sqliteIndex.js +342 -0
  145. package/dist/search/sqliteIndex.js.map +1 -0
  146. package/dist/server.d.ts +43 -0
  147. package/dist/server.d.ts.map +1 -0
  148. package/dist/server.js +532 -0
  149. package/dist/server.js.map +1 -0
  150. package/dist/session/bundle.d.ts +53 -0
  151. package/dist/session/bundle.d.ts.map +1 -0
  152. package/dist/session/bundle.js +97 -0
  153. package/dist/session/bundle.js.map +1 -0
  154. package/dist/storage/client.d.ts +157 -0
  155. package/dist/storage/client.d.ts.map +1 -0
  156. package/dist/storage/client.js +825 -0
  157. package/dist/storage/client.js.map +1 -0
  158. package/dist/tools/addWebhookPattern.d.ts +15 -0
  159. package/dist/tools/addWebhookPattern.d.ts.map +1 -0
  160. package/dist/tools/addWebhookPattern.js +15 -0
  161. package/dist/tools/addWebhookPattern.js.map +1 -0
  162. package/dist/tools/appendLog.d.ts +18 -0
  163. package/dist/tools/appendLog.d.ts.map +1 -0
  164. package/dist/tools/appendLog.js +18 -0
  165. package/dist/tools/appendLog.js.map +1 -0
  166. package/dist/tools/auditIndex.d.ts +15 -0
  167. package/dist/tools/auditIndex.d.ts.map +1 -0
  168. package/dist/tools/auditIndex.js +165 -0
  169. package/dist/tools/auditIndex.js.map +1 -0
  170. package/dist/tools/batchDeletePages.d.ts +18 -0
  171. package/dist/tools/batchDeletePages.d.ts.map +1 -0
  172. package/dist/tools/batchDeletePages.js +143 -0
  173. package/dist/tools/batchDeletePages.js.map +1 -0
  174. package/dist/tools/coerce.d.ts +3 -0
  175. package/dist/tools/coerce.d.ts.map +1 -0
  176. package/dist/tools/coerce.js +12 -0
  177. package/dist/tools/coerce.js.map +1 -0
  178. package/dist/tools/createPage.d.ts +24 -0
  179. package/dist/tools/createPage.d.ts.map +1 -0
  180. package/dist/tools/createPage.js +99 -0
  181. package/dist/tools/createPage.js.map +1 -0
  182. package/dist/tools/createPages.d.ts +51 -0
  183. package/dist/tools/createPages.d.ts.map +1 -0
  184. package/dist/tools/createPages.js +91 -0
  185. package/dist/tools/createPages.js.map +1 -0
  186. package/dist/tools/deletePage.d.ts +19 -0
  187. package/dist/tools/deletePage.d.ts.map +1 -0
  188. package/dist/tools/deletePage.js +127 -0
  189. package/dist/tools/deletePage.js.map +1 -0
  190. package/dist/tools/exportWiki.d.ts +48 -0
  191. package/dist/tools/exportWiki.d.ts.map +1 -0
  192. package/dist/tools/exportWiki.js +233 -0
  193. package/dist/tools/exportWiki.js.map +1 -0
  194. package/dist/tools/extractLinks.d.ts +14 -0
  195. package/dist/tools/extractLinks.d.ts.map +1 -0
  196. package/dist/tools/extractLinks.js +25 -0
  197. package/dist/tools/extractLinks.js.map +1 -0
  198. package/dist/tools/generateClaudeInstructions.d.ts +27 -0
  199. package/dist/tools/generateClaudeInstructions.d.ts.map +1 -0
  200. package/dist/tools/generateClaudeInstructions.js +263 -0
  201. package/dist/tools/generateClaudeInstructions.js.map +1 -0
  202. package/dist/tools/generateGptInstructions.d.ts +27 -0
  203. package/dist/tools/generateGptInstructions.d.ts.map +1 -0
  204. package/dist/tools/generateGptInstructions.js +208 -0
  205. package/dist/tools/generateGptInstructions.js.map +1 -0
  206. package/dist/tools/getBacklinks.d.ts +12 -0
  207. package/dist/tools/getBacklinks.d.ts.map +1 -0
  208. package/dist/tools/getBacklinks.js +13 -0
  209. package/dist/tools/getBacklinks.js.map +1 -0
  210. package/dist/tools/getRelated.d.ts +12 -0
  211. package/dist/tools/getRelated.d.ts.map +1 -0
  212. package/dist/tools/getRelated.js +13 -0
  213. package/dist/tools/getRelated.js.map +1 -0
  214. package/dist/tools/healthCheck.d.ts +5 -0
  215. package/dist/tools/healthCheck.d.ts.map +1 -0
  216. package/dist/tools/healthCheck.js +167 -0
  217. package/dist/tools/healthCheck.js.map +1 -0
  218. package/dist/tools/importBrainSource.d.ts +36 -0
  219. package/dist/tools/importBrainSource.d.ts.map +1 -0
  220. package/dist/tools/importBrainSource.js +314 -0
  221. package/dist/tools/importBrainSource.js.map +1 -0
  222. package/dist/tools/importWikiZip.d.ts +60 -0
  223. package/dist/tools/importWikiZip.d.ts.map +1 -0
  224. package/dist/tools/importWikiZip.js +379 -0
  225. package/dist/tools/importWikiZip.js.map +1 -0
  226. package/dist/tools/initProject.d.ts +34 -0
  227. package/dist/tools/initProject.d.ts.map +1 -0
  228. package/dist/tools/initProject.js +443 -0
  229. package/dist/tools/initProject.js.map +1 -0
  230. package/dist/tools/instructionContext.d.ts +60 -0
  231. package/dist/tools/instructionContext.d.ts.map +1 -0
  232. package/dist/tools/instructionContext.js +210 -0
  233. package/dist/tools/instructionContext.js.map +1 -0
  234. package/dist/tools/listArchivedPages.d.ts +17 -0
  235. package/dist/tools/listArchivedPages.d.ts.map +1 -0
  236. package/dist/tools/listArchivedPages.js +51 -0
  237. package/dist/tools/listArchivedPages.js.map +1 -0
  238. package/dist/tools/listPages.d.ts +28 -0
  239. package/dist/tools/listPages.d.ts.map +1 -0
  240. package/dist/tools/listPages.js +166 -0
  241. package/dist/tools/listPages.js.map +1 -0
  242. package/dist/tools/listWebhooks.d.ts +7 -0
  243. package/dist/tools/listWebhooks.d.ts.map +1 -0
  244. package/dist/tools/listWebhooks.js +10 -0
  245. package/dist/tools/listWebhooks.js.map +1 -0
  246. package/dist/tools/movePage.d.ts +18 -0
  247. package/dist/tools/movePage.d.ts.map +1 -0
  248. package/dist/tools/movePage.js +182 -0
  249. package/dist/tools/movePage.js.map +1 -0
  250. package/dist/tools/pageHistory.d.ts +36 -0
  251. package/dist/tools/pageHistory.d.ts.map +1 -0
  252. package/dist/tools/pageHistory.js +60 -0
  253. package/dist/tools/pageHistory.js.map +1 -0
  254. package/dist/tools/patchPage.d.ts +141 -0
  255. package/dist/tools/patchPage.d.ts.map +1 -0
  256. package/dist/tools/patchPage.js +287 -0
  257. package/dist/tools/patchPage.js.map +1 -0
  258. package/dist/tools/pullSessionBundle.d.ts +34 -0
  259. package/dist/tools/pullSessionBundle.d.ts.map +1 -0
  260. package/dist/tools/pullSessionBundle.js +432 -0
  261. package/dist/tools/pullSessionBundle.js.map +1 -0
  262. package/dist/tools/purgeWiki.d.ts +23 -0
  263. package/dist/tools/purgeWiki.d.ts.map +1 -0
  264. package/dist/tools/purgeWiki.js +113 -0
  265. package/dist/tools/purgeWiki.js.map +1 -0
  266. package/dist/tools/pushSessionBundle.d.ts +139 -0
  267. package/dist/tools/pushSessionBundle.d.ts.map +1 -0
  268. package/dist/tools/pushSessionBundle.js +282 -0
  269. package/dist/tools/pushSessionBundle.js.map +1 -0
  270. package/dist/tools/readGovernanceAudit.d.ts +17 -0
  271. package/dist/tools/readGovernanceAudit.d.ts.map +1 -0
  272. package/dist/tools/readGovernanceAudit.js +21 -0
  273. package/dist/tools/readGovernanceAudit.js.map +1 -0
  274. package/dist/tools/readPage.d.ts +13 -0
  275. package/dist/tools/readPage.d.ts.map +1 -0
  276. package/dist/tools/readPage.js +46 -0
  277. package/dist/tools/readPage.js.map +1 -0
  278. package/dist/tools/removeWebhook.d.ts +14 -0
  279. package/dist/tools/removeWebhook.d.ts.map +1 -0
  280. package/dist/tools/removeWebhook.js +12 -0
  281. package/dist/tools/removeWebhook.js.map +1 -0
  282. package/dist/tools/resolveProject.d.ts +58 -0
  283. package/dist/tools/resolveProject.d.ts.map +1 -0
  284. package/dist/tools/resolveProject.js +175 -0
  285. package/dist/tools/resolveProject.js.map +1 -0
  286. package/dist/tools/restorePage.d.ts +47 -0
  287. package/dist/tools/restorePage.d.ts.map +1 -0
  288. package/dist/tools/restorePage.js +111 -0
  289. package/dist/tools/restorePage.js.map +1 -0
  290. package/dist/tools/searchPages.d.ts +28 -0
  291. package/dist/tools/searchPages.d.ts.map +1 -0
  292. package/dist/tools/searchPages.js +372 -0
  293. package/dist/tools/searchPages.js.map +1 -0
  294. package/dist/tools/setWebhook.d.ts +30 -0
  295. package/dist/tools/setWebhook.d.ts.map +1 -0
  296. package/dist/tools/setWebhook.js +30 -0
  297. package/dist/tools/setWebhook.js.map +1 -0
  298. package/dist/tools/shareProject.d.ts +25 -0
  299. package/dist/tools/shareProject.d.ts.map +1 -0
  300. package/dist/tools/shareProject.js +56 -0
  301. package/dist/tools/shareProject.js.map +1 -0
  302. package/dist/tools/suggestLinks.d.ts +29 -0
  303. package/dist/tools/suggestLinks.d.ts.map +1 -0
  304. package/dist/tools/suggestLinks.js +214 -0
  305. package/dist/tools/suggestLinks.js.map +1 -0
  306. package/dist/tools/updatePage.d.ts +27 -0
  307. package/dist/tools/updatePage.d.ts.map +1 -0
  308. package/dist/tools/updatePage.js +118 -0
  309. package/dist/tools/updatePage.js.map +1 -0
  310. package/dist/webhooks/dispatch.d.ts +49 -0
  311. package/dist/webhooks/dispatch.d.ts.map +1 -0
  312. package/dist/webhooks/dispatch.js +159 -0
  313. package/dist/webhooks/dispatch.js.map +1 -0
  314. package/dist/webhooks/webhookRules.d.ts +34 -0
  315. package/dist/webhooks/webhookRules.d.ts.map +1 -0
  316. package/dist/webhooks/webhookRules.js +129 -0
  317. package/dist/webhooks/webhookRules.js.map +1 -0
  318. package/dist/wiki/frontmatter.d.ts +45 -0
  319. package/dist/wiki/frontmatter.d.ts.map +1 -0
  320. package/dist/wiki/frontmatter.js +68 -0
  321. package/dist/wiki/frontmatter.js.map +1 -0
  322. package/dist/wiki/index.d.ts +22 -0
  323. package/dist/wiki/index.d.ts.map +1 -0
  324. package/dist/wiki/index.js +203 -0
  325. package/dist/wiki/index.js.map +1 -0
  326. package/dist/wiki/ingestPolicy.d.ts +18 -0
  327. package/dist/wiki/ingestPolicy.d.ts.map +1 -0
  328. package/dist/wiki/ingestPolicy.js +64 -0
  329. package/dist/wiki/ingestPolicy.js.map +1 -0
  330. package/dist/wiki/layoutLint.d.ts +51 -0
  331. package/dist/wiki/layoutLint.d.ts.map +1 -0
  332. package/dist/wiki/layoutLint.js +146 -0
  333. package/dist/wiki/layoutLint.js.map +1 -0
  334. package/dist/wiki/links.d.ts +6 -0
  335. package/dist/wiki/links.d.ts.map +1 -0
  336. package/dist/wiki/links.js +45 -0
  337. package/dist/wiki/links.js.map +1 -0
  338. package/dist/wiki/log.d.ts +58 -0
  339. package/dist/wiki/log.d.ts.map +1 -0
  340. package/dist/wiki/log.js +126 -0
  341. package/dist/wiki/log.js.map +1 -0
  342. package/dist/wiki/path.d.ts +8 -0
  343. package/dist/wiki/path.d.ts.map +1 -0
  344. package/dist/wiki/path.js +25 -0
  345. package/dist/wiki/path.js.map +1 -0
  346. package/dist/wiki/storageLimits.d.ts +163 -0
  347. package/dist/wiki/storageLimits.d.ts.map +1 -0
  348. package/dist/wiki/storageLimits.js +281 -0
  349. package/dist/wiki/storageLimits.js.map +1 -0
  350. package/package.json +81 -0
@@ -0,0 +1,3913 @@
1
+ import http from "http";
2
+ import zlib from "zlib";
3
+ import { randomUUID } from "crypto";
4
+ import { promises as fs } from "fs";
5
+ import path from "path";
6
+ import { URL } from "url";
7
+ import AdmZip from "adm-zip";
8
+ import { z } from "zod";
9
+ import { createScopedStorageClient, GrantAwareStorageClient } from "./storage/client.js";
10
+ import { listActiveGrantsForGrantee } from "./access/projectShares.js";
11
+ import { createBrainsServer, executeToolCall, getVisibleTools, ToolNotAvailableError } from "./server.js";
12
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13
+ import { OAuthManager } from "./auth/oauth.js";
14
+ import { SupabaseAuthManager } from "./auth/supabase.js";
15
+ import { createTokenStore } from "./auth/tokenStore.js";
16
+ import { createClientStore } from "./auth/clientStore.js";
17
+ import { createPATStore, PAT_EXPIRY_DAYS_DEFAULT, PAT_EXPIRY_DAYS_MAX } from "./auth/patStore.js";
18
+ import { buildOpenApiSpec } from "./api/openapi.js";
19
+ import { renderErrorBox, renderMetaRows, renderStatusBadge, renderWordmark } from "./html/components.js";
20
+ import { renderPageShell } from "./html/layout.js";
21
+ import { getPostgresSearchIndex } from "./search/postgresIndex.js";
22
+ import { parseBundle } from "./session/bundle.js";
23
+ import { parsePage } from "./wiki/frontmatter.js";
24
+ import { pullSessionBundle } from "./tools/pullSessionBundle.js";
25
+ import { pushSessionBundle } from "./tools/pushSessionBundle.js";
26
+ import { buildExportZip } from "./tools/exportWiki.js";
27
+ import { runImportFromBuffer, isValidWikiFilename, computeDiscoveredFolders, buildInstructionRefreshHint, } from "./tools/importWikiZip.js";
28
+ import { purgeWiki } from "./tools/purgeWiki.js";
29
+ import { AsyncJobRegistry } from "./jobs/asyncJobRegistry.js";
30
+ import { appendLogEntry } from "./wiki/log.js";
31
+ import { DriveNotFoundError } from "./drive/errors.js";
32
+ import { StorageLimitError, PageLimitError, resolvePageLimitContext } from "./wiki/storageLimits.js";
33
+ import { insertRequestEvent } from "./observability/requestEvents.js";
34
+ import { queryMetrics, isValidWindow } from "./observability/metricsQuery.js";
35
+ import { metricsUiHtml } from "./html/metricsUi.js";
36
+ import { recordSearchLatency } from "./observability/slowQueryAlert.js";
37
+ import { recordSearch, maybeRecordRelevanceSignal } from "./observability/relevanceSignal.js";
38
+ import { startZeroResultTriageCron } from "./observability/zeroResultTriage.js";
39
+ import { fetchTopQueries, setHotCached } from "./search/hotQueryCache.js";
40
+ import { startDigestCron, runDigestForUser, } from "./digest/digestCron.js";
41
+ import { getDigestPreference, upsertDigestPreference, updateDigestEnabled, } from "./digest/digestPreferences.js";
42
+ import { DEFAULT_ACCESS_CONTEXT, getUserAccessLevel, isHiveModeEnabled, } from "./governance/accessControl.js";
43
+ import { seedUsers, purgeSeedUsers, isSeedEnabled, isGodModeSubject } from "./admin/seedUsers.js";
44
+ import { deleteAccount } from "./auth/deleteAccount.js";
45
+ const MAX_BODY_BYTES = 1024 * 1024;
46
+ const MAX_IMPORT_ZIP_UPLOAD_BYTES = 64 * 1024 * 1024;
47
+ // ── Launch spot counter (GET /api/v1/launch/status) ────────────────────────
48
+ // First-500 launch offer shown on the marketing pricing page. Counts total
49
+ // registered users (Supabase auth.users when configured, else local users/
50
+ // directories — same source priority as /admin/users) and caches the result
51
+ // for 60s so the pricing page can poll without hammering Supabase auth-admin.
52
+ const LAUNCH_SPOT_CAP = 500;
53
+ const LAUNCH_STATUS_CACHE_TTL_MS = 60_000;
54
+ let launchStatusCache = null;
55
+ async function countRegisteredUsersForLaunch(drive) {
56
+ const supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
57
+ const serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
58
+ if (supabaseUrl && serviceRoleKey) {
59
+ let total = 0;
60
+ let page = 1;
61
+ // eslint-disable-next-line no-constant-condition
62
+ while (true) {
63
+ const url = `${supabaseUrl}/auth/v1/admin/users?page=${page}&per_page=1000`;
64
+ const ac = new AbortController();
65
+ const acTimer = setTimeout(() => ac.abort(), 15_000);
66
+ const authHeaderValue = ["Bearer", serviceRoleKey].join(" ");
67
+ const resp = await fetch(url, {
68
+ headers: { apikey: serviceRoleKey, authorization: authHeaderValue },
69
+ signal: ac.signal,
70
+ }).catch(() => null).finally(() => clearTimeout(acTimer));
71
+ if (!resp || !resp.ok)
72
+ break;
73
+ const body = await resp.json().catch(() => null);
74
+ const batch = body?.users ?? [];
75
+ total += batch.length;
76
+ if (batch.length < 1000)
77
+ break;
78
+ page++;
79
+ }
80
+ return total;
81
+ }
82
+ const usersBasePath = path.join(drive.getStorageLocation(), "users");
83
+ const userDirs = await fs.readdir(usersBasePath, { withFileTypes: true }).catch(() => []);
84
+ return userDirs.filter((d) => d.isDirectory()).length;
85
+ }
86
+ async function getLaunchStatus(drive) {
87
+ const now = Date.now();
88
+ if (!launchStatusCache || now - launchStatusCache.cachedAt >= LAUNCH_STATUS_CACHE_TTL_MS) {
89
+ const count = await countRegisteredUsersForLaunch(drive).catch(() => launchStatusCache?.count ?? 0);
90
+ launchStatusCache = { count, cachedAt: now };
91
+ }
92
+ const spotsRemaining = Math.max(0, LAUNCH_SPOT_CAP - launchStatusCache.count);
93
+ return { spotsRemaining, offerOpen: spotsRemaining > 0 };
94
+ }
95
+ // Test-only: clears the 60s in-memory cache so tests can assert fresh counts
96
+ // without waiting out the TTL.
97
+ export function resetLaunchStatusCacheForTests() {
98
+ launchStatusCache = null;
99
+ }
100
+ class BodyTooLargeError extends Error {
101
+ maxBytes;
102
+ constructor(maxBytes) {
103
+ super(`Request body too large (max ${maxBytes} bytes)`);
104
+ this.maxBytes = maxBytes;
105
+ this.name = "BodyTooLargeError";
106
+ }
107
+ }
108
+ // ── MCP session helpers ───────────────────────────────────────────────────
109
+ /**
110
+ * Primes a freshly created StreamableHTTPServerTransport with a synthetic
111
+ * `initialize` request so the transport's `_initialized` flag and `sessionId`
112
+ * are set before we dispatch the client's actual non-initialize message.
113
+ *
114
+ * This is the server-side half of the "transparent reconnect" strategy: when a
115
+ * client sends a request with a stale Mcp-Session-Id (e.g. after a redeploy),
116
+ * we create a new transport, call this function, then handle the original
117
+ * request as if the session had never been lost. The client never receives a
118
+ * -32000 error and therefore never discards its OAuth tokens.
119
+ *
120
+ * Implementation note: this function accesses the Node.js transport wrapper's
121
+ * PRIVATE `_webStandardTransport` field via a type-cast. `_webStandardTransport`
122
+ * is intentionally a private SDK internal; it may be renamed or removed in future
123
+ * SDK versions. A runtime guard detects this and returns `false` so the caller
124
+ * can safely fall back to returning -32000. The `parsedBody` option passed to
125
+ * `handleRequest` is part of the SDK's public API surface (documented in
126
+ * `WebStandardStreamableHTTPServerTransportOptions`) and is stable.
127
+ *
128
+ * @returns `true` if the synthetic init succeeded, `false` if the private field
129
+ * was absent (SDK structural change) — in which case the caller must NOT
130
+ * register or use the transport.
131
+ */
132
+ async function syntheticMcpInit(transport) {
133
+ const initBody = {
134
+ jsonrpc: "2.0",
135
+ id: 0,
136
+ method: "initialize",
137
+ params: {
138
+ protocolVersion: "2025-03-26",
139
+ capabilities: {},
140
+ clientInfo: { name: "brains-reconnect", version: "1.0" },
141
+ },
142
+ };
143
+ // Access the inner web-standard transport via its private field. A runtime
144
+ // guard detects SDK structural changes. Callers MUST check the return value
145
+ // and abort if it is `false` to avoid registering an uninitialized transport.
146
+ const inner = transport._webStandardTransport;
147
+ if (!inner) {
148
+ console.error("[brains] syntheticMcpInit: _webStandardTransport field not found — SDK structure changed?");
149
+ return false;
150
+ }
151
+ const syntheticReq = new Request("http://localhost/mcp", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" },
154
+ });
155
+ const resp = await inner.handleRequest(syntheticReq, { parsedBody: initBody });
156
+ // Cancel the SSE/JSON response body so the ReadableStream can be GC'd.
157
+ resp.body?.cancel().catch(() => { });
158
+ return true;
159
+ }
160
+ // ── Response compression ──────────────────────────────────────────────────
161
+ const COMPRESSION_MIN_BYTES = 1024;
162
+ const SKIP_COMPRESSION_RE = /^(image\/|video\/|audio\/|font\/|application\/zip|application\/gzip|application\/x-br)/;
163
+ function selectEncoding(acceptEncoding) {
164
+ const ae = Array.isArray(acceptEncoding) ? acceptEncoding.join(",") : (acceptEncoding ?? "");
165
+ // Parse Accept-Encoding tokens per RFC 9110 §12.5.3: split on commas,
166
+ // strip optional quality values (;q=...) and whitespace, then exact-match.
167
+ const tokens = ae.split(",").map((t) => t.split(";")[0].trim().toLowerCase());
168
+ if (tokens.includes("br"))
169
+ return "br";
170
+ if (tokens.includes("gzip"))
171
+ return "gzip";
172
+ return null;
173
+ }
174
+ function compressIfNeeded(req, contentType, buf) {
175
+ if (!req || buf.length < COMPRESSION_MIN_BYTES)
176
+ return { buf, encoding: null };
177
+ if (SKIP_COMPRESSION_RE.test(contentType))
178
+ return { buf, encoding: null };
179
+ const encoding = selectEncoding(req.headers["accept-encoding"]);
180
+ if (!encoding)
181
+ return { buf, encoding: null };
182
+ const compressed = encoding === "br"
183
+ ? zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
184
+ : zlib.gzipSync(buf);
185
+ return { buf: compressed, encoding };
186
+ }
187
+ function classifyRouteForRateLimit(method, pathname) {
188
+ if (method === "GET" && pathname === "/api/v1/search")
189
+ return "search";
190
+ if (method === "GET" &&
191
+ (pathname === "/api/v1/page" || pathname === "/api/v1/pages" || pathname.endsWith("/backlinks") || pathname.endsWith("/related") || pathname.endsWith("/history"))) {
192
+ return "reads";
193
+ }
194
+ if (method === "POST" && (pathname === "/api/v1/pages/batch" || pathname === "/api/v1/import" || pathname === "/api/v1/links/extract" || pathname === "/api/v1/links/suggest" || pathname === "/api/v1/pages/delete-batch"))
195
+ return "batch";
196
+ if (method === "POST" && pathname === "/chat")
197
+ return "chat";
198
+ if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")
199
+ return "writes";
200
+ return null;
201
+ }
202
+ function checkRateLimit(windows, key, limit, windowMs) {
203
+ // Returns null when the request is allowed.
204
+ // Returns the remaining milliseconds until the oldest timestamp exits the
205
+ // window (i.e. the earliest the client may retry) when the limit is exceeded.
206
+ const now = Date.now();
207
+ const cutoff = now - windowMs;
208
+ const timestamps = windows.get(key) ?? [];
209
+ const pruned = timestamps.filter((t) => t > cutoff);
210
+ if (pruned.length >= limit) {
211
+ windows.set(key, pruned);
212
+ // pruned is sorted ascending (timestamps are appended in arrival order).
213
+ // The oldest entry is pruned[0]; when it falls out of the window the
214
+ // client earns one new slot.
215
+ const oldest = pruned[0];
216
+ return oldest + windowMs - now;
217
+ }
218
+ pruned.push(now);
219
+ windows.set(key, pruned);
220
+ return null;
221
+ }
222
+ // ── Timeout race helper ───────────────────────────────────────────────────
223
+ const TIMEOUT_SENTINEL = Symbol("timeout");
224
+ function raceWithTimeout(promise, ms) {
225
+ let timer;
226
+ const timeoutPromise = new Promise((resolve) => {
227
+ timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms);
228
+ });
229
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
230
+ }
231
+ function emitRequestLog(method, reqPath, status, durationMs, extras = {}) {
232
+ const logFormat = (process.env.LOG_FORMAT ?? "").toLowerCase();
233
+ if (logFormat === "json") {
234
+ const entry = { ts: new Date().toISOString(), method, path: reqPath, status, durationMs };
235
+ if (extras.backend !== undefined)
236
+ entry["backend"] = extras.backend;
237
+ if (extras.queryMs !== undefined)
238
+ entry["queryMs"] = extras.queryMs;
239
+ if (extras.resultCount !== undefined)
240
+ entry["resultCount"] = extras.resultCount;
241
+ if (extras.queryText !== undefined)
242
+ entry["queryText"] = extras.queryText;
243
+ if (extras.zeroResults)
244
+ entry["zero-results"] = true;
245
+ console.log(JSON.stringify(entry));
246
+ }
247
+ else {
248
+ let line = `[req] ${method} ${reqPath} ${status} ${durationMs}ms`;
249
+ if (extras.backend !== undefined)
250
+ line += ` backend=${extras.backend}`;
251
+ if (extras.queryMs !== undefined)
252
+ line += ` queryMs=${extras.queryMs}`;
253
+ if (extras.resultCount !== undefined)
254
+ line += ` resultCount=${extras.resultCount}`;
255
+ if (extras.queryText !== undefined)
256
+ line += ` query=${JSON.stringify(extras.queryText)}`;
257
+ if (extras.zeroResults)
258
+ line += ` zero-results=true`;
259
+ console.log(line);
260
+ }
261
+ }
262
+ function json(req, res, statusCode, payload, extraHeaders) {
263
+ const bodyStr = JSON.stringify(payload);
264
+ const bodyBuf = Buffer.from(bodyStr, "utf8");
265
+ const { buf: outBuf, encoding } = compressIfNeeded(req, "application/json", bodyBuf);
266
+ const headers = {
267
+ "content-type": "application/json; charset=utf-8",
268
+ "content-length": outBuf.length.toString(),
269
+ "cache-control": "no-store",
270
+ ...extraHeaders,
271
+ };
272
+ if (encoding) {
273
+ headers["content-encoding"] = encoding;
274
+ headers["vary"] = "Accept-Encoding";
275
+ }
276
+ res.writeHead(statusCode, headers);
277
+ res.end(outBuf);
278
+ }
279
+ function parseBearerToken(req) {
280
+ const auth = req.headers.authorization ?? "";
281
+ if (!auth.startsWith("Bearer "))
282
+ return null;
283
+ return auth.slice(7);
284
+ }
285
+ function parseBasicClientCredentials(req) {
286
+ const auth = req.headers.authorization ?? "";
287
+ if (!auth.startsWith("Basic "))
288
+ return null;
289
+ try {
290
+ const decoded = Buffer.from(auth.slice(6), "base64").toString("utf8");
291
+ const split = decoded.indexOf(":");
292
+ if (split <= 0)
293
+ return null;
294
+ return {
295
+ clientId: decoded.slice(0, split),
296
+ clientSecret: decoded.slice(split + 1),
297
+ };
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ }
303
+ function isApiKeyAuthorized(req) {
304
+ const apiKey = process.env.BRAINS_API_KEY;
305
+ if (!apiKey)
306
+ return true;
307
+ const bearer = parseBearerToken(req);
308
+ if (bearer && bearer === apiKey)
309
+ return true;
310
+ const headerKey = req.headers["x-api-key"];
311
+ if (typeof headerKey === "string" && headerKey === apiKey)
312
+ return true;
313
+ return false;
314
+ }
315
+ function isConnectorKeyAuthorized(parsedUrl) {
316
+ const connectorKey = process.env.BRAINS_CONNECTOR_KEY;
317
+ if (!connectorKey)
318
+ return false;
319
+ const providedKey = parsedUrl.searchParams.get("connector_key");
320
+ if (!providedKey)
321
+ return false;
322
+ return providedKey === connectorKey;
323
+ }
324
+ function isPublicRoute(method, pathname) {
325
+ return ((method === "GET" && pathname === "/") ||
326
+ (method === "GET" && pathname === "/cb") ||
327
+ (method === "GET" && pathname === "/chat-ui") ||
328
+ (method === "GET" && pathname === "/connect") ||
329
+ (method === "POST" && pathname === "/api/inbound-email") ||
330
+ (method === "GET" && pathname.startsWith("/assets/css/")) ||
331
+ (method === "GET" && pathname.startsWith("/assets/img/")) ||
332
+ (method === "GET" && pathname === "/openapi.json") ||
333
+ (method === "GET" && pathname === "/.well-known/openapi.json") ||
334
+ (method === "GET" && pathname === "/api/v1") ||
335
+ (method === "GET" && pathname === "/.well-known/oauth-authorization-server") ||
336
+ (method === "GET" && pathname === "/.well-known/oauth-protected-resource") ||
337
+ pathname === "/oauth/authorize" ||
338
+ pathname === "/oauth/token" ||
339
+ pathname === "/oauth/register" ||
340
+ pathname === "/oauth/signup" ||
341
+ pathname === "/oauth/revoke" ||
342
+ (method === "GET" && pathname === "/healthz") ||
343
+ (method === "GET" && pathname === "/api/v1/health") ||
344
+ (method === "GET" && pathname === "/api/v1/launch/status"));
345
+ }
346
+ function html(req, res, statusCode, body) {
347
+ const bodyBuf = Buffer.from(body, "utf8");
348
+ const { buf: outBuf, encoding } = compressIfNeeded(req, "text/html", bodyBuf);
349
+ const headers = {
350
+ "content-type": "text/html; charset=utf-8",
351
+ "content-length": outBuf.length.toString(),
352
+ "cache-control": "no-store",
353
+ };
354
+ if (encoding) {
355
+ headers["content-encoding"] = encoding;
356
+ headers["vary"] = "Accept-Encoding";
357
+ }
358
+ res.writeHead(statusCode, headers);
359
+ res.end(outBuf);
360
+ }
361
+ const CSS_ASSET_PREFIX = "/assets/css/";
362
+ const CSS_ROOT = path.resolve(process.cwd(), "src/styles");
363
+ const IMG_ASSET_PREFIX = "/assets/img/";
364
+ const IMG_ROOT = path.resolve(process.cwd(), "src/assets/img");
365
+ async function imgAsset(res, pathname) {
366
+ const relativePath = decodeURIComponent(pathname.slice(IMG_ASSET_PREFIX.length));
367
+ if (!relativePath) {
368
+ json(null, res, 404, { ok: false, error: "Not found" });
369
+ return;
370
+ }
371
+ const resolved = path.resolve(IMG_ROOT, relativePath);
372
+ if (resolved === IMG_ROOT || !resolved.startsWith(`${IMG_ROOT}${path.sep}`)) {
373
+ json(null, res, 404, { ok: false, error: "Not found" });
374
+ return;
375
+ }
376
+ try {
377
+ const buf = await fs.readFile(resolved);
378
+ const ext = path.extname(resolved).toLowerCase();
379
+ const ct = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png" : "application/octet-stream";
380
+ res.writeHead(200, { "content-type": ct, "content-length": buf.length.toString(), "cache-control": "public, max-age=300" });
381
+ res.end(buf);
382
+ }
383
+ catch (err) {
384
+ const code = err.code;
385
+ if (code === "ENOENT" || code === "ENOTDIR") {
386
+ json(null, res, 404, { ok: false, error: "Not found" });
387
+ return;
388
+ }
389
+ throw err;
390
+ }
391
+ }
392
+ function resolveCssFilePath(pathname) {
393
+ if (!pathname.startsWith(CSS_ASSET_PREFIX))
394
+ return null;
395
+ const relativePath = decodeURIComponent(pathname.slice(CSS_ASSET_PREFIX.length));
396
+ if (!relativePath || !relativePath.endsWith(".css"))
397
+ return null;
398
+ const resolved = path.resolve(CSS_ROOT, relativePath);
399
+ if (resolved === CSS_ROOT)
400
+ return null;
401
+ if (!resolved.startsWith(`${CSS_ROOT}${path.sep}`))
402
+ return null;
403
+ return resolved;
404
+ }
405
+ async function cssAsset(res, pathname) {
406
+ const filePath = resolveCssFilePath(pathname);
407
+ if (!filePath) {
408
+ json(null, res, 404, { ok: false, error: "Not found" });
409
+ return;
410
+ }
411
+ try {
412
+ const body = await fs.readFile(filePath, "utf8");
413
+ res.writeHead(200, {
414
+ "content-type": "text/css; charset=utf-8",
415
+ "content-length": Buffer.byteLength(body).toString(),
416
+ "cache-control": "public, max-age=300",
417
+ });
418
+ res.end(body);
419
+ return;
420
+ }
421
+ catch (err) {
422
+ const code = err.code;
423
+ if (code === "ENOENT" || code === "ENOTDIR") {
424
+ json(null, res, 404, { ok: false, error: "Not found" });
425
+ return;
426
+ }
427
+ throw err;
428
+ }
429
+ }
430
+ // ── Editor SPA static asset serving ──────────────────────────────────────
431
+ const EDITOR_DIST = path.resolve(process.cwd(), "dist/editor");
432
+ const EDITOR_PREFIX = "/editor";
433
+ const MIME = {
434
+ ".html": "text/html; charset=utf-8",
435
+ ".js": "text/javascript; charset=utf-8",
436
+ ".css": "text/css; charset=utf-8",
437
+ ".svg": "image/svg+xml",
438
+ ".png": "image/png",
439
+ ".ico": "image/x-icon",
440
+ ".woff2": "font/woff2",
441
+ ".woff": "font/woff",
442
+ ".ttf": "font/ttf",
443
+ ".json": "application/json; charset=utf-8",
444
+ };
445
+ async function serveEditorAsset(res, assetPath) {
446
+ const ext = path.extname(assetPath).toLowerCase();
447
+ const contentType = MIME[ext] ?? "application/octet-stream";
448
+ const isHtml = contentType.startsWith("text/html");
449
+ const cacheControl = isHtml ? "no-store" : "public, max-age=31536000, immutable";
450
+ try {
451
+ const buf = await fs.readFile(assetPath);
452
+ res.writeHead(200, {
453
+ "content-type": contentType,
454
+ "content-length": buf.length.toString(),
455
+ "cache-control": cacheControl,
456
+ });
457
+ res.end(buf);
458
+ }
459
+ catch (err) {
460
+ const code = err.code;
461
+ if (code === "ENOENT" || code === "ENOTDIR") {
462
+ // SPA fallback: serve index.html for any unmatched path under /editor
463
+ try {
464
+ const indexBuf = await fs.readFile(path.join(EDITOR_DIST, "index.html"));
465
+ res.writeHead(200, {
466
+ "content-type": "text/html; charset=utf-8",
467
+ "content-length": indexBuf.length.toString(),
468
+ "cache-control": "no-store",
469
+ });
470
+ res.end(indexBuf);
471
+ }
472
+ catch {
473
+ res.writeHead(404, { "content-type": "text/plain" });
474
+ res.end("Editor frontend moved to apps/web (/notes route)");
475
+ }
476
+ return;
477
+ }
478
+ throw err;
479
+ }
480
+ }
481
+ async function readBodyBuffer(req, maxBytes = MAX_BODY_BYTES) {
482
+ const chunks = [];
483
+ let bytes = 0;
484
+ for await (const chunk of req) {
485
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
486
+ bytes += buf.length;
487
+ if (bytes > maxBytes) {
488
+ throw new BodyTooLargeError(maxBytes);
489
+ }
490
+ chunks.push(buf);
491
+ }
492
+ return Buffer.concat(chunks);
493
+ }
494
+ async function readBody(req, maxBytes = MAX_BODY_BYTES) {
495
+ return (await readBodyBuffer(req, maxBytes)).toString("utf8");
496
+ }
497
+ async function scanImportDiffFromBuffer(drive, buffer, opts) {
498
+ const zip = new AdmZip(buffer);
499
+ const entries = zip.getEntries().filter((e) => !e.isDirectory);
500
+ const diff = [];
501
+ let newCount = 0;
502
+ let modifiedCount = 0;
503
+ let unchangedCount = 0;
504
+ let skippedCount = 0;
505
+ let deletedCount = 0;
506
+ const systemFiles = new Set(["index.md", "log.md"]);
507
+ const zipNames = new Set();
508
+ const effectiveConflictStrategy = opts.mode === "replace" ? "overwrite" : opts.conflictStrategy;
509
+ let scanProcessed = 0;
510
+ for (const entry of entries) {
511
+ const name = entry.entryName.replace(/\\/g, "/");
512
+ if (name === "export-index.json")
513
+ continue;
514
+ try {
515
+ // Reject path-traversal and absolute-path entries before any filesystem access.
516
+ if (name.startsWith("/") || name.split("/").some((segment) => segment === "..")) {
517
+ diff.push({ name, status: "skipped" });
518
+ skippedCount++;
519
+ continue;
520
+ }
521
+ if (!opts.includeSystem && systemFiles.has(name)) {
522
+ diff.push({ name, status: "skipped" });
523
+ skippedCount++;
524
+ continue;
525
+ }
526
+ if (!isValidWikiFilename(name)) {
527
+ diff.push({ name, status: "skipped" });
528
+ skippedCount++;
529
+ continue;
530
+ }
531
+ zipNames.add(name);
532
+ const incomingContent = entry.getData().toString("utf8");
533
+ const existing = await drive
534
+ .readFile(name)
535
+ .catch((err) => (err instanceof DriveNotFoundError ? null : Promise.reject(err)));
536
+ if (!existing) {
537
+ diff.push({ name, status: "new" });
538
+ newCount++;
539
+ continue;
540
+ }
541
+ if (existing.content === incomingContent) {
542
+ diff.push({ name, status: "unchanged" });
543
+ unchangedCount++;
544
+ continue;
545
+ }
546
+ if (effectiveConflictStrategy === "skip") {
547
+ diff.push({ name, status: "skipped" });
548
+ skippedCount++;
549
+ continue;
550
+ }
551
+ diff.push({ name, status: "modified" });
552
+ modifiedCount++;
553
+ }
554
+ finally {
555
+ opts.onProgress?.(++scanProcessed, entries.length);
556
+ }
557
+ }
558
+ if (opts.mode === "replace") {
559
+ const existingFiles = await drive.listFiles();
560
+ for (const existing of existingFiles) {
561
+ const name = existing.name.replace(/\\/g, "/");
562
+ if (!opts.includeSystem && systemFiles.has(name)) {
563
+ continue;
564
+ }
565
+ if (!zipNames.has(name)) {
566
+ diff.push({ name, status: "deleted" });
567
+ deletedCount++;
568
+ }
569
+ }
570
+ }
571
+ return { totalEntries: entries.length, newCount, modifiedCount, unchangedCount, skippedCount, deletedCount, diff };
572
+ }
573
+ async function readJsonBody(req) {
574
+ const raw = (await readBody(req)).trim();
575
+ if (!raw)
576
+ return {};
577
+ return JSON.parse(raw);
578
+ }
579
+ async function readFormBody(req) {
580
+ const raw = (await readBody(req)).trim();
581
+ const params = new URLSearchParams(raw);
582
+ const output = {};
583
+ for (const [key, value] of params.entries()) {
584
+ output[key] = value;
585
+ }
586
+ return output;
587
+ }
588
+ const toolCallSchema = z.object({
589
+ name: z.string().min(1),
590
+ arguments: z.unknown().optional(),
591
+ });
592
+ const chatMessageSchema = z.object({
593
+ role: z.enum(["user", "assistant"]),
594
+ content: z.string(),
595
+ });
596
+ const chatRequestSchema = z.object({
597
+ apiKey: z.string().min(20),
598
+ model: z.string().optional(),
599
+ maxTokens: z.number().int().min(256).max(8192).optional(),
600
+ message: z.string().optional(),
601
+ messages: z.array(chatMessageSchema).optional(),
602
+ });
603
+ const createPageHttpSchema = z.object({
604
+ name: z.string().min(1),
605
+ title: z.string().min(1),
606
+ body: z.string(),
607
+ tags: z.array(z.string()).optional(),
608
+ summary: z.string().optional(),
609
+ });
610
+ const createPagesHttpSchema = z.object({
611
+ pages: z.array(createPageHttpSchema).min(1).max(20),
612
+ verbose: z.boolean().optional(),
613
+ runLabel: z.string().optional(),
614
+ logTiming: z.boolean().optional(),
615
+ });
616
+ const updatePageHttpSchema = z.object({
617
+ name: z.string().min(1),
618
+ fileId: z.string().min(1),
619
+ revision: z.string().min(1),
620
+ body: z.string(),
621
+ frontmatterPatch: z.record(z.unknown()).optional(),
622
+ summary: z.string().optional(),
623
+ });
624
+ const patchPageHttpSchema = z.object({
625
+ name: z.string().min(1),
626
+ operations: z.array(z.record(z.unknown())).min(1).max(20),
627
+ lineNumbersAre: z.string().optional(),
628
+ summary: z.string().optional(),
629
+ });
630
+ const movePageHttpSchema = z.object({
631
+ name: z.string().min(1),
632
+ action: z.enum(["archive", "rename"]),
633
+ newName: z.string().optional(),
634
+ });
635
+ const appendLogHttpSchema = z.object({
636
+ type: z.enum(["ingest", "query", "update", "create", "move", "lint", "error"]),
637
+ title: z.string().min(1),
638
+ detail: z.string().optional(),
639
+ });
640
+ const auditIndexHttpSchema = z.object({
641
+ cleanup: z.boolean().optional(),
642
+ });
643
+ const extractLinksHttpSchema = z.object({
644
+ tags: z.array(z.string()).optional(),
645
+ folder: z.string().optional(),
646
+ userId: z.string().optional(),
647
+ });
648
+ const suggestLinksHttpSchema = z.object({
649
+ tags: z.array(z.string()).optional(),
650
+ folder: z.string().optional(),
651
+ maxPages: z.number().int().min(1).max(50).optional(),
652
+ });
653
+ const importBrainSourceHttpSchema = z.object({
654
+ sourceRoot: z.string().min(1),
655
+ execute: z.boolean().optional(),
656
+ maxFiles: z.number().int().min(1).max(5000).optional(),
657
+ skipRawCatalogs: z.boolean().optional(),
658
+ excludeDirs: z.array(z.string()).optional(),
659
+ chunkSize: z.number().int().min(1).max(20).optional(),
660
+ runLabel: z.string().optional(),
661
+ logTiming: z.boolean().optional(),
662
+ previewLimit: z.number().int().min(1).max(200).optional(),
663
+ });
664
+ const exportWikiHttpSchema = z.object({
665
+ outputPath: z.string().min(1),
666
+ includeSystem: z.boolean().optional(),
667
+ folder: z.string().optional(),
668
+ tags: z.array(z.string()).optional(),
669
+ });
670
+ const importWikiZipHttpSchema = z.object({
671
+ zipPath: z.string().min(1),
672
+ execute: z.boolean().optional(),
673
+ conflictStrategy: z.enum(["skip", "overwrite", "merge"]).optional(),
674
+ includeSystem: z.boolean().optional(),
675
+ logTiming: z.boolean().optional(),
676
+ });
677
+ const purgeWikiHttpSchema = z.object({
678
+ confirm: z.boolean().optional(),
679
+ exportBeforeDelete: z.boolean().optional(),
680
+ exportOutputPath: z.string().optional(),
681
+ includeSystem: z.boolean().optional(),
682
+ });
683
+ const toolInvokeHttpSchema = z.object({
684
+ arguments: z.unknown().optional(),
685
+ });
686
+ // Form POST always sends empty strings for unset hidden fields; coerce them to undefined.
687
+ const emptyToUndefined = (v) => (v === "" ? undefined : v);
688
+ const oauthAuthorizeQuerySchema = z.object({
689
+ response_type: z.literal("code"),
690
+ client_id: z.string().min(1),
691
+ redirect_uri: z.string().url(),
692
+ scope: z.preprocess(emptyToUndefined, z.string().optional()),
693
+ state: z.preprocess(emptyToUndefined, z.string().optional()),
694
+ code_challenge: z.preprocess(emptyToUndefined, z.string().optional()),
695
+ code_challenge_method: z.preprocess(emptyToUndefined, z.enum(["S256", "plain"]).optional()),
696
+ });
697
+ const oauthAuthorizeFormSchema = oauthAuthorizeQuerySchema.extend({
698
+ username: z.string().min(1),
699
+ password: z.string().min(1),
700
+ });
701
+ const oauthSignupFormSchema = oauthAuthorizeQuerySchema.extend({
702
+ email: z.string().email(),
703
+ password: z.string().min(8),
704
+ confirm_password: z.string().min(1),
705
+ });
706
+ const oauthTokenFormSchema = z.object({
707
+ grant_type: z.enum(["authorization_code", "refresh_token"]),
708
+ code: z.string().optional(),
709
+ redirect_uri: z.string().url().optional(),
710
+ client_id: z.string().optional(),
711
+ client_secret: z.string().optional(),
712
+ code_verifier: z.string().optional(),
713
+ refresh_token: z.string().optional(),
714
+ });
715
+ const oauthRevokeFormSchema = z.object({
716
+ token: z.string().min(1),
717
+ token_type_hint: z.enum(["access_token", "refresh_token"]).optional(),
718
+ client_id: z.string().optional(),
719
+ client_secret: z.string().optional(),
720
+ });
721
+ const createPATHttpSchema = z.object({
722
+ label: z.string().min(1).max(255),
723
+ scopes: z.array(z.enum(["wiki.read", "wiki.write"])).optional(),
724
+ // null = never expires; omit to use default (30 days)
725
+ expiresInDays: z.number().int().min(1).max(PAT_EXPIRY_DAYS_MAX).nullable().optional(),
726
+ });
727
+ function chatUiHtml() {
728
+ const body = `<main class="app-shell chat-wrap">
729
+ ${renderWordmark("Chat with Claude + MCP tools using your brains server.")}
730
+ <section class="panel card">
731
+ <h1 class="page-title">brains Chat</h1>
732
+ <form class="chat-form" onsubmit="return false;">
733
+ <div class="form-row">
734
+ <label for="brainsKey">Brains API Key (BRAINS_API_KEY)</label>
735
+ <input class="input" id="brainsKey" type="password" placeholder="Optional if server has no BRAINS_API_KEY" />
736
+ </div>
737
+ <div class="form-row">
738
+ <label for="claudeKey">Claude API Key (Anthropic)</label>
739
+ <input class="input" id="claudeKey" type="password" placeholder="sk-ant-..." />
740
+ </div>
741
+ <div class="form-row">
742
+ <label for="model">Model</label>
743
+ <input class="input" id="model" value="claude-3-7-sonnet-latest" />
744
+ </div>
745
+ <div class="form-row">
746
+ <label for="message">Message</label>
747
+ <textarea class="textarea" id="message" placeholder="Ask about your brain, then tools will be called automatically."></textarea>
748
+ </div>
749
+ <button class="btn btn--block" id="send" type="button">Send</button>
750
+ </form>
751
+ <div class="log" id="log"></div>
752
+ </section>
753
+
754
+ <script>
755
+ const log = document.getElementById("log");
756
+ const send = document.getElementById("send");
757
+ const brainsKey = document.getElementById("brainsKey");
758
+ const claudeKey = document.getElementById("claudeKey");
759
+ const model = document.getElementById("model");
760
+ const message = document.getElementById("message");
761
+
762
+ let history = [];
763
+
764
+ function push(role, text, cls = "") {
765
+ const div = document.createElement("div");
766
+ div.className = "msg " + role + (cls ? (" " + cls) : "");
767
+ div.textContent = (role === "user" ? "You: " : "Claude: ") + text;
768
+ log.appendChild(div);
769
+ log.scrollTop = log.scrollHeight;
770
+ }
771
+
772
+ send.addEventListener("click", async () => {
773
+ const userText = message.value.trim();
774
+ if (!userText) return;
775
+ if (!claudeKey.value.trim()) {
776
+ push("assistant", "Claude API key is required.", "error");
777
+ return;
778
+ }
779
+
780
+ push("user", userText);
781
+ history.push({ role: "user", content: userText });
782
+ message.value = "";
783
+
784
+ try {
785
+ const headers = { "content-type": "application/json" };
786
+ if (brainsKey.value.trim()) headers["x-api-key"] = brainsKey.value.trim();
787
+
788
+ const res = await fetch("/chat", {
789
+ method: "POST",
790
+ headers,
791
+ body: JSON.stringify({
792
+ apiKey: claudeKey.value.trim(),
793
+ model: model.value.trim(),
794
+ messages: history,
795
+ }),
796
+ });
797
+
798
+ const data = await res.json();
799
+ if (!res.ok || !data.ok) {
800
+ push("assistant", data.error || "Request failed", "error");
801
+ return;
802
+ }
803
+
804
+ history = data.messages || history;
805
+ push("assistant", data.assistant || "(No text returned)");
806
+ } catch (err) {
807
+ push("assistant", String(err), "error");
808
+ }
809
+ });
810
+ </script>
811
+ </main>`;
812
+ return renderPageShell({
813
+ title: "brains Chat",
814
+ body,
815
+ bodyClass: "chat-page",
816
+ cssPaths: ["/assets/css/pages/chat.css"],
817
+ });
818
+ }
819
+ function hasRequiredScopes(ownedScopes, requiredScopes) {
820
+ return requiredScopes.every((scope) => ownedScopes.includes(scope));
821
+ }
822
+ function unauthorized(res, resourceMetadataUrl) {
823
+ const wwwAuth = resourceMetadataUrl
824
+ ? `Bearer resource_metadata="${resourceMetadataUrl}"`
825
+ : "Bearer";
826
+ res.setHeader("WWW-Authenticate", wwwAuth);
827
+ json(null, res, 401, {
828
+ ok: false,
829
+ error: "Unauthorized",
830
+ detail: "Use a valid bearer token (Supabase/OAuth), x-api-key, or connector_key query parameter",
831
+ });
832
+ }
833
+ function forbidden(res, requiredScopes) {
834
+ json(null, res, 403, {
835
+ ok: false,
836
+ error: "Forbidden",
837
+ detail: `Missing required scopes: ${requiredScopes.join(", ")}`,
838
+ });
839
+ }
840
+ function authorizeRequest(req, parsedUrl, oauth, supabaseAuth, patStore) {
841
+ if (isConnectorKeyAuthorized(parsedUrl)) {
842
+ return Promise.resolve({
843
+ scopes: ["wiki.read", "wiki.write"],
844
+ subject: "connector-key",
845
+ mode: "connector-key",
846
+ });
847
+ }
848
+ if (isApiKeyAuthorized(req)) {
849
+ return Promise.resolve({
850
+ scopes: ["wiki.read", "wiki.write"],
851
+ subject: "api-key",
852
+ mode: "api-key",
853
+ });
854
+ }
855
+ const bearer = parseBearerToken(req);
856
+ if (!bearer)
857
+ return Promise.resolve(null);
858
+ return (async () => {
859
+ // PAT lookup first. NOTE: this is only a local DB check when Supabase is
860
+ // unconfigured — production selects PostgresPATStore, where find() is an
861
+ // HTTP round trip. It is cached (60s, invalidated on revoke) precisely
862
+ // because it lands on every authenticated request, and because PATs are
863
+ // indistinguishable from OAuth tokens so this runs even for OAuth callers
864
+ // that will always miss (GH #427).
865
+ const pat = await patStore.find(bearer);
866
+ if (pat) {
867
+ return {
868
+ scopes: pat.scopes,
869
+ subject: pat.subject,
870
+ mode: "pat",
871
+ };
872
+ }
873
+ const supabase = await supabaseAuth.authenticateAccessToken(bearer);
874
+ if (supabase) {
875
+ return {
876
+ scopes: supabase.scopes,
877
+ subject: supabase.subject,
878
+ mode: "supabase",
879
+ };
880
+ }
881
+ const access = await oauth.authenticateAccessToken(bearer);
882
+ if (!access)
883
+ return null;
884
+ return {
885
+ scopes: access.scopes,
886
+ subject: access.subject,
887
+ mode: "oauth",
888
+ };
889
+ })();
890
+ }
891
+ /**
892
+ * Normalizes an OAuth/Supabase user subject (UUID or email) into a
893
+ * filesystem/object-storage safe path segment: lowercase, chars outside
894
+ * [a-z0-9._-] replaced with underscore, leading/trailing underscores
895
+ * stripped, capped at 96 characters.
896
+ *
897
+ * This value becomes the second path segment: `users/<subjectKey>/…`.
898
+ * The output must be stable across calls for the same subject because it
899
+ * is used as the durable user-scope prefix in storage.
900
+ *
901
+ * Exported for unit testing only — call sites outside this module should
902
+ * not depend on the sanitization details.
903
+ */
904
+ export function sanitizeSubjectForPath(subject) {
905
+ const trimmed = subject.trim().toLowerCase();
906
+ const normalized = trimmed.replace(/[^a-z0-9._-]/g, "_").replace(/^_+|_+$/g, "");
907
+ return normalized.slice(0, 96) || "user";
908
+ }
909
+ /**
910
+ * Returns true only for auth modes that carry a stable per-user identity
911
+ * (`oauth` and `supabase`). API-key and connector-key requests are
912
+ * intentionally routed to the shared/global storage root because those
913
+ * credentials are workspace-wide, not user-specific.
914
+ *
915
+ * DO NOT add `api-key` or `connector-key` here without a corresponding
916
+ * key-management model that maps keys to owner identities.
917
+ */
918
+ function shouldUseUserScopedStorage(mode) {
919
+ return mode === "oauth" || mode === "supabase" || mode === "pat";
920
+ }
921
+ function oauthAuthorizeFormHtml(params) {
922
+ const activeTab = params.tab ?? "signin";
923
+ const hiddenOAuthFields = `
924
+ <input type="hidden" name="response_type" value="code" />
925
+ <input type="hidden" name="client_id" value="${escapeHtml(params.clientId)}" />
926
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(params.redirectUri)}" />
927
+ <input type="hidden" name="scope" value="${escapeHtml(params.scope)}" />
928
+ <input type="hidden" name="state" value="${escapeHtml(params.state)}" />
929
+ <input type="hidden" name="code_challenge" value="${escapeHtml(params.codeChallenge)}" />
930
+ <input type="hidden" name="code_challenge_method" value="${escapeHtml(params.codeChallengeMethod)}" />`;
931
+ const errorHtml = renderErrorBox(params.error ? escapeHtml(params.error) : undefined);
932
+ const body = `<main class="app-shell auth-wrap">
933
+ ${renderWordmark()}
934
+ <h1 class="auth-headline">Turn on your brain</h1>
935
+ <section class="panel auth-panel">
936
+
937
+ <div class="tabs">
938
+ <button type="button" class="tab${activeTab === "signin" ? " active" : ""}" data-tab="signin">Sign in</button>
939
+ <button type="button" class="tab${activeTab === "signup" ? " active" : ""}" data-tab="signup">Create account</button>
940
+ </div>
941
+
942
+ <div id="panel-signin" class="panel-block${activeTab === "signin" ? " active" : ""}">
943
+ <form method="post" action="/oauth/authorize">
944
+ ${hiddenOAuthFields}
945
+ ${activeTab === "signin" ? errorHtml : ""}
946
+ <div class="form-row">
947
+ <label>Email</label>
948
+ <input class="input" name="username" type="email" autocomplete="email" required />
949
+ </div>
950
+
951
+ <div class="form-row">
952
+ <label>Password</label>
953
+ <input class="input" name="password" type="password" autocomplete="current-password" required />
954
+ </div>
955
+
956
+ <div class="form-row">
957
+ <button class="btn btn--block" type="submit">Authorize</button>
958
+ </div>
959
+ </form>
960
+ </div>
961
+
962
+ <div id="panel-signup" class="panel-block${activeTab === "signup" ? " active" : ""}">
963
+ <form method="post" action="/oauth/signup" id="signup-form">
964
+ ${hiddenOAuthFields}
965
+ ${activeTab === "signup" ? errorHtml : ""}
966
+ <p class="auth-platforms-note">Works with your favourite AI assistant:</p>
967
+ <div class="auth-platforms">
968
+ <span class="auth-platform-pill">✦ ChatGPT</span>
969
+ <span class="auth-platform-pill">◆ Claude</span>
970
+ <span class="auth-platform-pill">⬡ Copilot</span>
971
+ <span class="auth-platform-pill auth-platform-pill--soon">✶ Gemini (soon)</span>
972
+ </div>
973
+ <div class="form-row">
974
+ <label>Email</label>
975
+ <input class="input" name="email" type="email" autocomplete="email" required />
976
+ </div>
977
+
978
+ <div class="form-row">
979
+ <label>Password</label>
980
+ <input class="input" name="password" id="su-password" type="password" autocomplete="new-password" minlength="8" required />
981
+ <div class="hint">At least 8 characters.</div>
982
+ <div id="pw-length-error" class="inline-error">Password must be at least 8 characters.</div>
983
+ </div>
984
+
985
+ <div class="form-row">
986
+ <label>Confirm password</label>
987
+ <input class="input" name="confirm_password" id="su-confirm" type="password" autocomplete="new-password" required />
988
+ <div id="pw-match-error" class="inline-error">Passwords do not match.</div>
989
+ </div>
990
+
991
+ <div class="form-row">
992
+ <button class="btn btn--block" type="submit">Create account</button>
993
+ </div>
994
+ </form>
995
+ </div>
996
+
997
+ ${renderMetaRows([
998
+ { label: "Client", value: escapeHtml(params.clientId) },
999
+ { label: "Scopes", value: escapeHtml(params.scope) },
1000
+ ])}
1001
+ </section>
1002
+
1003
+ <script>
1004
+ document.querySelectorAll(".tab").forEach(function(btn) {
1005
+ btn.addEventListener("click", function() {
1006
+ var target = btn.getAttribute("data-tab");
1007
+ document.querySelectorAll(".tab").forEach(function(t) { t.classList.remove("active"); });
1008
+ document.querySelectorAll(".panel-block").forEach(function(p) { p.classList.remove("active"); });
1009
+ btn.classList.add("active");
1010
+ document.getElementById("panel-" + target).classList.add("active");
1011
+ });
1012
+ });
1013
+
1014
+ var form = document.getElementById("signup-form");
1015
+ var pwInput = document.getElementById("su-password");
1016
+ var confirmInput = document.getElementById("su-confirm");
1017
+ var pwLenErr = document.getElementById("pw-length-error");
1018
+ var pwMatchErr = document.getElementById("pw-match-error");
1019
+
1020
+ form.addEventListener("submit", function(e) {
1021
+ var ok = true;
1022
+ if (pwInput.value.length < 8) {
1023
+ pwLenErr.style.display = "block";
1024
+ ok = false;
1025
+ } else {
1026
+ pwLenErr.style.display = "none";
1027
+ }
1028
+ if (pwInput.value !== confirmInput.value) {
1029
+ pwMatchErr.style.display = "block";
1030
+ ok = false;
1031
+ } else {
1032
+ pwMatchErr.style.display = "none";
1033
+ }
1034
+ if (!ok) e.preventDefault();
1035
+ });
1036
+ </script>
1037
+ </main>`;
1038
+ return renderPageShell({
1039
+ title: "brains OAuth Consent",
1040
+ body,
1041
+ bodyClass: "auth-page",
1042
+ cssPaths: ["/assets/css/pages/auth.css"],
1043
+ });
1044
+ }
1045
+ function signupConfirmHtml(params) {
1046
+ const body = `<main class="app-shell auth-wrap">
1047
+ ${renderWordmark()}
1048
+ <h1 class="auth-headline">Check your email</h1>
1049
+ <section class="panel auth-panel">
1050
+ <p>We sent a confirmation link to <strong>${escapeHtml(params.email)}</strong>.<br />Click the link in the email to activate your account, then come back here to sign in.</p>
1051
+ <div class="form-row">
1052
+ <a class="btn" href="${escapeHtml(params.backPath)}">Back to sign in</a>
1053
+ </div>
1054
+ </section>
1055
+ </main>`;
1056
+ return renderPageShell({
1057
+ title: "Check your email - brains",
1058
+ body,
1059
+ bodyClass: "auth-page",
1060
+ cssPaths: ["/assets/css/pages/auth.css"],
1061
+ });
1062
+ }
1063
+ function oauthSuccessHtml(params) {
1064
+ const safeRedirectUri = escapeHtml(params.redirectUri);
1065
+ const body = `<main class="app-shell auth-wrap">
1066
+ ${renderWordmark()}
1067
+ <h1 class="auth-headline">brains connected</h1>
1068
+ <section class="panel auth-panel">
1069
+ <p>Your brains account is now connected. Return to the app — you may close this tab.</p>
1070
+ <p class="auth-hint">If the app did not open automatically, <a href="${safeRedirectUri}">click here to complete the connection</a>.</p>
1071
+ </section>
1072
+ <script>
1073
+ try { window.location.href = ${JSON.stringify(params.redirectUri)}; } catch (_) {}
1074
+ </script>
1075
+ </main>`;
1076
+ return renderPageShell({
1077
+ title: "Connected - brains",
1078
+ body,
1079
+ bodyClass: "auth-page",
1080
+ cssPaths: ["/assets/css/pages/auth.css"],
1081
+ });
1082
+ }
1083
+ function oauthCallbackHtml(params) {
1084
+ const valueOrNone = (value) => (value ? escapeHtml(value) : "<em>(none)</em>");
1085
+ const hasError = Boolean(params.error);
1086
+ const body = `<main class="app-shell callback-wrap">
1087
+ ${renderWordmark("OAuth callback payload for local testing and code exchange.")}
1088
+ <section class="panel card mono">
1089
+ <h2 class="page-title">OAuth callback received</h2>
1090
+ <p>Use the values below for local testing or exchange the authorization code at <code>/oauth/token</code>.</p>
1091
+ ${renderStatusBadge(hasError ? "err" : "ok", hasError ? "Authorization error" : "Authorization success")}
1092
+ <div class="kv-grid">
1093
+ <div class="kv-label">code</div>
1094
+ <div class="kv-value">${valueOrNone(params.code)}</div>
1095
+ <div class="kv-label">state</div>
1096
+ <div class="kv-value">${valueOrNone(params.state)}</div>
1097
+ <div class="kv-label">error</div>
1098
+ <div class="kv-value">${valueOrNone(params.error)}</div>
1099
+ <div class="kv-label">error_description</div>
1100
+ <div class="kv-value">${valueOrNone(params.errorDescription)}</div>
1101
+ </div>
1102
+ </section>
1103
+ </main>`;
1104
+ return renderPageShell({
1105
+ title: "OAuth Callback - brains",
1106
+ body,
1107
+ bodyClass: "callback-page",
1108
+ cssPaths: ["/assets/css/pages/callback.css"],
1109
+ });
1110
+ }
1111
+ function escapeHtml(value) {
1112
+ return value
1113
+ .replaceAll("&", "&amp;")
1114
+ .replaceAll("<", "&lt;")
1115
+ .replaceAll(">", "&gt;")
1116
+ .replaceAll("\"", "&quot;")
1117
+ .replaceAll("'", "&#39;");
1118
+ }
1119
+ function connectPageHtml(base, _registeredClientIds) {
1120
+ const mcpUrl = `${base}/mcp`;
1121
+ const platforms = {
1122
+ chatgpt: { name: "ChatGPT", emoji: "✦" },
1123
+ claude: { name: "Claude", emoji: "◆" },
1124
+ copilot: { name: "GitHub Copilot · VS Code", emoji: "⬡" },
1125
+ "copilot-app": { name: "GitHub Copilot · app", emoji: "⬡" },
1126
+ gemini: { name: "Gemini", emoji: "✶" },
1127
+ };
1128
+ const vscodeJsonHtml = escapeHtml(`{\n "brains": {\n "type": "http",\n "url": "${mcpUrl}"\n }\n}`);
1129
+ const steps = [
1130
+ {
1131
+ title: "Add the app",
1132
+ description: "Connect brains to your AI assistant using the MCP Server URL below.",
1133
+ bodyHtml: `
1134
+ <p class="cj-url-label">MCP Server URL</p>
1135
+ <div class="cj-url-block"><code>${escapeHtml(mcpUrl)}</code></div>`,
1136
+ accordions: [
1137
+ {
1138
+ platformId: "chatgpt",
1139
+ steps: [
1140
+ "<strong>Do this on <a href=\"https://chatgpt.com\" target=\"_blank\" rel=\"noopener\">chatgpt.com</a></strong> in a browser — the Mac and iOS apps may not support custom MCP connectors yet.",
1141
+ "Go to <strong>Settings → Apps</strong>, then <strong>Advanced settings</strong> and enable <strong>Developer mode</strong>.",
1142
+ "Back in <strong>Settings → Apps</strong>, click <strong>Add app</strong>.",
1143
+ "Enter a name like <em>brains</em>, paste the MCP Server URL above into the <strong>MCP Server URL</strong> field.",
1144
+ "Leave Authentication as <strong>OAuth</strong> and click <strong>Create</strong>.",
1145
+ ],
1146
+ },
1147
+ {
1148
+ platformId: "claude",
1149
+ steps: [
1150
+ "Go to <strong>Claude.ai → Settings → Integrations</strong> and click <strong>Add integration</strong>.",
1151
+ "Paste the MCP Server URL above and give the integration a name like <em>brains</em>.",
1152
+ "Click <strong>Add</strong>.",
1153
+ ],
1154
+ },
1155
+ {
1156
+ platformId: "copilot",
1157
+ steps: [
1158
+ "Open (or create) <code>.vscode/mcp.json</code> in your project (or the global VS Code MCP settings).",
1159
+ `Add the following entry and save:<br><pre class="connect-code">${vscodeJsonHtml}</pre>`,
1160
+ "VS Code detects the server and shows a sign-in prompt — continue to step 2.",
1161
+ ],
1162
+ },
1163
+ {
1164
+ platformId: "copilot-app",
1165
+ steps: [
1166
+ "Click <strong>Generate token</strong> below — you must be signed in to brains.",
1167
+ `Copy the full config block that appears and save it as <code>~/.copilot/mcp-config.json</code> (create the file if it doesn't exist).`,
1168
+ ],
1169
+ footerHtml: `<div style="margin-top:0.75rem">
1170
+ <button class="btn" id="gen-pat-btn" type="button" style="font-size:0.85rem;padding:0.45rem 0.9rem;min-height:0">Generate token</button>
1171
+ <p id="pat-msg" style="display:none;margin:0.5rem 0 0;font-size:0.84rem;color:var(--error,#b00)"></p>
1172
+ <pre class="connect-code" id="pat-config-out" style="display:none;margin-top:0.6rem;user-select:all"></pre>
1173
+ </div>`,
1174
+ },
1175
+ { platformId: "gemini", soon: true, steps: [] },
1176
+ ],
1177
+ },
1178
+ {
1179
+ title: "Authenticate",
1180
+ description: "Sign in or create a brains account to link your personal wiki. Each user gets their own private, isolated space.",
1181
+ bodyHtml: "",
1182
+ accordions: [
1183
+ {
1184
+ platformId: "chatgpt",
1185
+ steps: [
1186
+ "After clicking <strong>Create</strong>, ChatGPT opens the brains sign-in page.",
1187
+ "Click the <strong>Create account</strong> tab and sign up with your email.",
1188
+ "Authorise access — ChatGPT returns you to your conversation.",
1189
+ "<strong>Note:</strong> ChatGPT may show an error saying it couldn't complete the task after you return. This is a known quirk — the connection almost certainly worked. Continue to step 3 and try using brains; if it responds, you're all set.",
1190
+ ],
1191
+ },
1192
+ {
1193
+ platformId: "claude",
1194
+ steps: [
1195
+ "After adding the integration, Claude redirects you to the brains sign-in page.",
1196
+ "Click the <strong>Create account</strong> tab and sign up with your email.",
1197
+ "Authorise access — you'll be returned to Claude.",
1198
+ ],
1199
+ },
1200
+ {
1201
+ platformId: "copilot",
1202
+ steps: [
1203
+ "VS Code shows an <strong>Allow</strong> sign-in prompt — click it.",
1204
+ "A browser tab opens with the brains sign-in page.",
1205
+ "Click the <strong>Create account</strong> tab, sign up with your email, and authorise.",
1206
+ ],
1207
+ },
1208
+ {
1209
+ platformId: "copilot-app",
1210
+ steps: [
1211
+ "Authentication is handled by the token you generated — no sign-in popup needed.",
1212
+ ],
1213
+ },
1214
+ { platformId: "gemini", soon: true, steps: [] },
1215
+ ],
1216
+ },
1217
+ {
1218
+ title: "Activate for a project",
1219
+ description: "Connecting brains makes tools available but passive — nothing runs automatically. To enable active memory, run init_project once in your project and paste the output into your project's instruction settings.",
1220
+ bodyHtml: `
1221
+ <p class="cj-step-hint">Run this prompt inside your project:</p>
1222
+ <div class="cj-prompt">&ldquo;Set up brains for this project.&rdquo;</div>`,
1223
+ accordions: [
1224
+ {
1225
+ platformId: "chatgpt",
1226
+ steps: [
1227
+ "Open or create a <strong>Project</strong> in ChatGPT, then run the prompt above.",
1228
+ "brains saves a page like <code>projects/&lt;your-project&gt;/instructions/gpt.md</code> — open it and copy the contents.",
1229
+ "In the project, click <strong>Edit project</strong> and paste into the <strong>Instructions</strong> field.",
1230
+ ],
1231
+ },
1232
+ {
1233
+ platformId: "claude",
1234
+ steps: [
1235
+ "Open or create a <strong>Project</strong> in Claude.ai, then run the prompt above.",
1236
+ "brains saves a page like <code>projects/&lt;your-project&gt;/instructions/claude.md</code> — open it and copy the contents.",
1237
+ "In the project, go to <strong>Project instructions</strong> and paste.",
1238
+ ],
1239
+ },
1240
+ {
1241
+ platformId: "copilot",
1242
+ steps: [
1243
+ "In VS Code with a project open, run the prompt above in Copilot Chat.",
1244
+ "brains saves an instructions page — open it and copy the contents.",
1245
+ "Paste into <code>.github/copilot-instructions.md</code> at the root of your project.",
1246
+ ],
1247
+ },
1248
+ {
1249
+ platformId: "copilot-app",
1250
+ steps: [
1251
+ "In the Copilot app with a project open, run the prompt above.",
1252
+ "brains saves an instructions page — open it and copy the contents.",
1253
+ "Paste into <code>.github/copilot-instructions.md</code> at the root of your project.",
1254
+ ],
1255
+ },
1256
+ { platformId: "gemini", soon: true, steps: [] },
1257
+ ],
1258
+ },
1259
+ {
1260
+ title: "Happy braining",
1261
+ description: "You're all set. Your assistant can now read, write, and search your private wiki — and remember things across sessions.",
1262
+ bodyHtml: `
1263
+ <ul class="cj-examples">
1264
+ <li><em>&ldquo;Save the spec sheet for my project car.&rdquo;</em></li>
1265
+ <li><em>&ldquo;Remember that Sarah at Acme is the main contact for billing questions.&rdquo;</em></li>
1266
+ <li><em>&ldquo;Save a decision record — we're going with Instagram over TikTok for the launch.&rdquo;</em></li>
1267
+ <li><em>&ldquo;What do we know about the Henderson account?&rdquo;</em></li>
1268
+ </ul>`,
1269
+ accordions: [],
1270
+ },
1271
+ ];
1272
+ function renderAccordions(accordions) {
1273
+ if (accordions.length === 0)
1274
+ return "";
1275
+ const items = accordions.map((acc) => {
1276
+ const p = platforms[acc.platformId];
1277
+ if (acc.soon) {
1278
+ return `<details class="cj-accordion cj-accordion--soon" aria-disabled="true">
1279
+ <summary>
1280
+ <span class="cj-acc-emoji">${p.emoji}</span>
1281
+ <span>${escapeHtml(p.name)}</span>
1282
+ <span class="cj-acc-soon">Coming soon</span>
1283
+ </summary>
1284
+ </details>`;
1285
+ }
1286
+ const stepsHtml = acc.steps
1287
+ .map((s, i) => `<li><span class="step-num">${i + 1}</span><span>${s}</span></li>`)
1288
+ .join("");
1289
+ return `<details class="cj-accordion">
1290
+ <summary>
1291
+ <span class="cj-acc-emoji">${p.emoji}</span>
1292
+ <span>${escapeHtml(p.name)}</span>
1293
+ </summary>
1294
+ <div class="cj-accordion-body">
1295
+ <ol class="connect-steps">${stepsHtml}</ol>
1296
+ ${acc.footerHtml ?? ""}
1297
+ </div>
1298
+ </details>`;
1299
+ }).join("\n ");
1300
+ return `<div class="cj-accordions">${items}</div>`;
1301
+ }
1302
+ const stepsHtml = steps.map((step, i) => `
1303
+ <div class="cj-step">
1304
+ <div class="cj-step-header">
1305
+ <span class="cj-step-num">${i + 1}</span>
1306
+ <div>
1307
+ <h2 class="cj-step-title">${escapeHtml(step.title)}</h2>
1308
+ <p class="cj-step-desc">${escapeHtml(step.description)}</p>
1309
+ </div>
1310
+ </div>
1311
+ <div class="cj-step-body">
1312
+ ${step.bodyHtml}
1313
+ ${renderAccordions(step.accordions)}
1314
+ </div>
1315
+ </div>`).join("\n");
1316
+ const body = `<main class="app-shell connect-wrap">
1317
+ ${renderWordmark("Connect your AI assistant to your personal wiki.")}
1318
+ <section class="connect-journey">
1319
+ ${stepsHtml}
1320
+ </section>
1321
+ <script>
1322
+ (function () {
1323
+ var btn = document.getElementById('gen-pat-btn');
1324
+ var out = document.getElementById('pat-config-out');
1325
+ var msg = document.getElementById('pat-msg');
1326
+ if (!btn) return;
1327
+ btn.addEventListener('click', async function () {
1328
+ btn.disabled = true;
1329
+ btn.textContent = 'Generating…';
1330
+ msg.style.display = 'none';
1331
+ out.style.display = 'none';
1332
+ try {
1333
+ var r = await fetch('/api/v1/tokens', {
1334
+ method: 'POST',
1335
+ headers: { 'content-type': 'application/json' },
1336
+ body: JSON.stringify({ label: 'Copilot app', expiresInDays: 365 })
1337
+ });
1338
+ var d = await r.json();
1339
+ if (!r.ok || !d.ok) {
1340
+ if (r.status === 401 || r.status === 403) {
1341
+ msg.innerHTML = 'Sign in to brains first (<a href="/">brains.app</a>), then return here and click Generate token.';
1342
+ } else {
1343
+ msg.textContent = d.error || 'Failed to generate token.';
1344
+ }
1345
+ msg.style.display = '';
1346
+ btn.disabled = false;
1347
+ btn.textContent = 'Generate token';
1348
+ return;
1349
+ }
1350
+ out.textContent = JSON.stringify({
1351
+ mcpServers: {
1352
+ brains: {
1353
+ type: 'http',
1354
+ url: ${JSON.stringify(mcpUrl)},
1355
+ headers: { Authorization: 'Bearer ' + d.token }
1356
+ }
1357
+ }
1358
+ }, null, 2);
1359
+ out.style.display = '';
1360
+ btn.textContent = 'Regenerate token';
1361
+ btn.disabled = false;
1362
+ } catch (err) {
1363
+ msg.textContent = 'Network error: ' + err.message;
1364
+ msg.style.display = '';
1365
+ btn.disabled = false;
1366
+ btn.textContent = 'Generate token';
1367
+ }
1368
+ });
1369
+ })();
1370
+ </script>
1371
+ </main>`;
1372
+ return renderPageShell({
1373
+ title: "Connect to brains",
1374
+ body,
1375
+ bodyClass: "connect-page",
1376
+ cssPaths: ["/assets/css/pages/connect.css"],
1377
+ });
1378
+ }
1379
+ function redirectWithParams(res, redirectUri, params) {
1380
+ const url = new URL(redirectUri);
1381
+ for (const [key, value] of Object.entries(params)) {
1382
+ if (!value)
1383
+ continue;
1384
+ url.searchParams.set(key, value);
1385
+ }
1386
+ res.writeHead(302, { location: url.toString() });
1387
+ res.end();
1388
+ }
1389
+ function coerceNumber(value) {
1390
+ if (value === null || value.trim() === "")
1391
+ return undefined;
1392
+ const n = Number(value);
1393
+ if (!Number.isFinite(n))
1394
+ return undefined;
1395
+ return n;
1396
+ }
1397
+ async function callAnthropic(apiKey, model, maxTokens, messages) {
1398
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
1399
+ method: "POST",
1400
+ headers: {
1401
+ "content-type": "application/json",
1402
+ "x-api-key": apiKey,
1403
+ "anthropic-version": "2023-06-01",
1404
+ },
1405
+ body: JSON.stringify({
1406
+ model,
1407
+ max_tokens: maxTokens,
1408
+ tools: getVisibleTools().map((t) => ({
1409
+ name: t.name,
1410
+ description: t.description,
1411
+ input_schema: t.inputSchema,
1412
+ })),
1413
+ messages,
1414
+ }),
1415
+ });
1416
+ const text = await response.text();
1417
+ if (!response.ok) {
1418
+ throw new Error(`Anthropic API error (${response.status}): ${text}`);
1419
+ }
1420
+ return JSON.parse(text);
1421
+ }
1422
+ async function runClaudeWithTools(drive, req, userId = "", accessCtx = DEFAULT_ACCESS_CONTEXT) {
1423
+ const model = req.model ?? process.env.CLAUDE_MODEL ?? "claude-3-7-sonnet-latest";
1424
+ const maxTokens = req.maxTokens ?? 1500;
1425
+ const history = [...(req.messages ?? [])];
1426
+ if (req.message && req.message.trim()) {
1427
+ history.push({ role: "user", content: req.message.trim() });
1428
+ }
1429
+ const anthropicMessages = history.map((m) => ({
1430
+ role: m.role,
1431
+ content: [{ type: "text", text: m.content }],
1432
+ }));
1433
+ let finalText = "";
1434
+ for (let i = 0; i < 8; i += 1) {
1435
+ const response = await callAnthropic(req.apiKey, model, maxTokens, anthropicMessages);
1436
+ const content = Array.isArray(response.content) ? response.content : [];
1437
+ anthropicMessages.push({ role: "assistant", content });
1438
+ const textParts = content
1439
+ .filter((c) => c.type === "text")
1440
+ .map((c) => c.text);
1441
+ if (textParts.length > 0) {
1442
+ finalText = textParts.join("\n").trim();
1443
+ }
1444
+ const toolUses = content.filter((c) => c.type === "tool_use");
1445
+ if (toolUses.length === 0) {
1446
+ break;
1447
+ }
1448
+ const toolResults = [];
1449
+ for (const toolUse of toolUses) {
1450
+ try {
1451
+ const result = await executeToolCall(drive, String(toolUse.name), toolUse.input ?? {}, userId, accessCtx);
1452
+ toolResults.push({
1453
+ type: "tool_result",
1454
+ tool_use_id: String(toolUse.id),
1455
+ content: JSON.stringify(result),
1456
+ });
1457
+ }
1458
+ catch (toolErr) {
1459
+ const errMsg = toolErr instanceof Error ? toolErr.message : String(toolErr);
1460
+ toolResults.push({
1461
+ type: "tool_result",
1462
+ tool_use_id: String(toolUse.id),
1463
+ content: errMsg,
1464
+ is_error: true,
1465
+ });
1466
+ }
1467
+ }
1468
+ anthropicMessages.push({ role: "user", content: toolResults });
1469
+ }
1470
+ if (!finalText) {
1471
+ finalText = "(Claude returned no text output)";
1472
+ }
1473
+ history.push({ role: "assistant", content: finalText });
1474
+ return { assistant: finalText, messages: history };
1475
+ }
1476
+ /**
1477
+ * After bootstrap, walk every file in the scoped storage and upsert it into
1478
+ * the Postgres search index. Runs entirely in the background — the caller's
1479
+ * first request is never delayed. Safe to call multiple times (upsert is
1480
+ * idempotent). No-ops when Postgres is not configured.
1481
+ */
1482
+ async function backfillIndexForUser(scoped, userId) {
1483
+ const idx = getPostgresSearchIndex();
1484
+ if (!idx.isConfigured())
1485
+ return;
1486
+ let files;
1487
+ try {
1488
+ files = await scoped.listFiles();
1489
+ }
1490
+ catch {
1491
+ return;
1492
+ }
1493
+ for (const entry of files) {
1494
+ try {
1495
+ const wiki = await scoped.readFile(entry.name);
1496
+ const { frontmatter } = parsePage(wiki.content);
1497
+ // Full content (frontmatter included) — matches patchPage.ts's DB-primary
1498
+ // convention, which read_page/patch_page read back as the complete file.
1499
+ await idx.upsertPage(entry.name, frontmatter, wiki.content, wiki.revision, wiki.modifiedTime, userId);
1500
+ }
1501
+ catch {
1502
+ // Non-fatal — skip unreadable / unparseable files
1503
+ }
1504
+ }
1505
+ }
1506
+ async function countMdFiles(dir) {
1507
+ let count = 0;
1508
+ const walk = async (d) => {
1509
+ const entries = await fs.readdir(d, { withFileTypes: true }).catch(() => []);
1510
+ for (const e of entries) {
1511
+ if (e.isDirectory()) {
1512
+ await walk(path.join(d, e.name));
1513
+ continue;
1514
+ }
1515
+ if (e.isFile() && e.name.endsWith(".md"))
1516
+ count++;
1517
+ }
1518
+ };
1519
+ await walk(dir);
1520
+ return count;
1521
+ }
1522
+ async function calcDirBytes(dir) {
1523
+ let bytes = 0;
1524
+ const walk = async (d) => {
1525
+ const entries = await fs.readdir(d, { withFileTypes: true }).catch(() => []);
1526
+ for (const e of entries) {
1527
+ if (e.isDirectory()) {
1528
+ await walk(path.join(d, e.name));
1529
+ continue;
1530
+ }
1531
+ if (e.isFile()) {
1532
+ const stat = await fs.stat(path.join(d, e.name)).catch(() => null);
1533
+ if (stat)
1534
+ bytes += stat.size;
1535
+ }
1536
+ }
1537
+ };
1538
+ await walk(dir);
1539
+ return bytes;
1540
+ }
1541
+ export async function startHttpServer(drive, port) {
1542
+ const tokenStore = createTokenStore(drive.getStorageLocation());
1543
+ const clientStore = createClientStore(drive.getStorageLocation());
1544
+ const patStore = createPATStore(drive.getStorageLocation());
1545
+ const oauth = new OAuthManager(tokenStore, clientStore);
1546
+ const supabaseAuth = new SupabaseAuthManager();
1547
+ const openApi = buildOpenApiSpec();
1548
+ // ── Per-request timeouts ──────────────────────────────────────────────────
1549
+ const TIMEOUT_DEFAULT_MS = parseInt(process.env.TIMEOUT_DEFAULT_MS ?? "30000");
1550
+ const TIMEOUT_CHAT_MS = parseInt(process.env.TIMEOUT_CHAT_MS ?? "120000");
1551
+ const TIMEOUT_IMPORT_MS = parseInt(process.env.TIMEOUT_IMPORT_MS ?? "300000");
1552
+ // ── Rate limiting (per-instance state so tests can run in isolation) ────────
1553
+ const RATE_LIMIT_ENABLED = (process.env.RATE_LIMIT_ENABLED ?? "true").toLowerCase() !== "false";
1554
+ const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000");
1555
+ const RATE_LIMITS = {
1556
+ search: parseInt(process.env.RATE_LIMIT_SEARCH ?? "60"),
1557
+ reads: parseInt(process.env.RATE_LIMIT_READS ?? "120"),
1558
+ writes: parseInt(process.env.RATE_LIMIT_WRITES ?? "30"),
1559
+ batch: parseInt(process.env.RATE_LIMIT_BATCH ?? "5"),
1560
+ chat: parseInt(process.env.RATE_LIMIT_CHAT ?? "10"),
1561
+ };
1562
+ // Map<"class:subject", number[]> — timestamp arrays pruned on each check
1563
+ const rateLimitWindows = new Map();
1564
+ // ── Concurrency caps ──────────────────────────────────────────────────────
1565
+ const CONCURRENCY_LIMIT_BATCH = parseInt(process.env.CONCURRENCY_LIMIT_BATCH ?? "3");
1566
+ const CONCURRENCY_LIMIT_IMPORT = parseInt(process.env.CONCURRENCY_LIMIT_IMPORT ?? "1");
1567
+ const CONCURRENCY_LIMIT_PURGE = parseInt(process.env.CONCURRENCY_LIMIT_PURGE ?? "1");
1568
+ const CONCURRENCY_LIMIT_CHAT = parseInt(process.env.CONCURRENCY_LIMIT_CHAT ?? "5");
1569
+ let batchInFlight = 0;
1570
+ let importInFlight = 0;
1571
+ let purgeInFlight = 0;
1572
+ let chatInFlight = 0;
1573
+ const asyncJobRegistry = new AsyncJobRegistry();
1574
+ // ── FTS startup backfill ──────────────────────────────────────────────────
1575
+ // Detect and fix rows where fts_vector is NULL — happens when data is
1576
+ // migrated to a new Supabase project before the trigger is applied.
1577
+ if ((process.env.STORAGE_BACKEND ?? "local").toLowerCase() === "supabase") {
1578
+ getPostgresSearchIndex().reindexFts().then(({ updated }) => {
1579
+ if (updated > 0)
1580
+ console.log(`[startup] FTS reindex: backfilled ${updated} rows`);
1581
+ }).catch((err) => {
1582
+ console.error("[startup] FTS reindex error:", err instanceof Error ? err.message : err);
1583
+ });
1584
+ }
1585
+ function publicBase() {
1586
+ const raw = (process.env.BRAINS_BASE_URL?.trim() ||
1587
+ process.env.BRAINS_PUBLIC_BASE_URL?.trim() ||
1588
+ "https://brains-staging.up.railway.app").replace(/\/$/, "");
1589
+ return raw || `http://localhost:${port}`;
1590
+ }
1591
+ function webUrl() {
1592
+ return process.env.WEB_URL?.trim().replace(/\/$/, "") || undefined;
1593
+ }
1594
+ function oauthAuthorizationServerMetadata() {
1595
+ const base = publicBase();
1596
+ return {
1597
+ issuer: base,
1598
+ authorization_endpoint: `${base}/oauth/authorize`,
1599
+ token_endpoint: `${base}/oauth/token`,
1600
+ registration_endpoint: `${base}/oauth/register`,
1601
+ revocation_endpoint: `${base}/oauth/revoke`,
1602
+ response_types_supported: ["code"],
1603
+ grant_types_supported: ["authorization_code", "refresh_token"],
1604
+ code_challenge_methods_supported: ["S256", "plain"],
1605
+ scopes_supported: ["wiki.read", "wiki.write"],
1606
+ token_endpoint_auth_methods_supported: [...new Set(["none", ...oauth.getTokenEndpointAuthMethods()])],
1607
+ revocation_endpoint_auth_methods_supported: [...new Set(["none", ...oauth.getTokenEndpointAuthMethods()])],
1608
+ };
1609
+ }
1610
+ function oauthProtectedResourceMetadata() {
1611
+ const base = publicBase();
1612
+ return {
1613
+ resource: `${base}/mcp`,
1614
+ authorization_servers: [base],
1615
+ scopes_supported: ["wiki.read", "wiki.write"],
1616
+ bearer_methods_supported: ["header"],
1617
+ };
1618
+ }
1619
+ const enableRemoteMcp = (process.env.BRAINS_ENABLE_REMOTE_MCP ?? "false").toLowerCase() === "true";
1620
+ // Cache scoped StorageClient promises per user subject. Each entry also
1621
+ // records the last time the cache was validated for an external wipe so we
1622
+ // only re-check once per TTL rather than on every request.
1623
+ const SCOPED_DRIVE_WIPE_CHECK_TTL_MS = 5 * 60 * 1000; // 5 minutes
1624
+ const scopedDriveCache = new Map();
1625
+ const mcpSessions = new Map();
1626
+ // Deduplicates concurrent reconnect attempts for the same stale session ID.
1627
+ // Keyed by session ID; value is a Promise that resolves to the newly created
1628
+ // session (or null if syntheticMcpInit failed).
1629
+ const mcpReconnectPromises = new Map();
1630
+ /**
1631
+ * Returns the storage client scoped to the authenticated user.
1632
+ *
1633
+ * Scoping behavior by auth mode:
1634
+ * oauth / supabase → isolated prefix `users/<sanitized-subject>/`
1635
+ * api-key → shared global root (intentional, by design)
1636
+ * connector-key → shared global root (intentional, by design)
1637
+ * unauthenticated → shared global root
1638
+ *
1639
+ * Scoped clients are cached per subject for the lifetime of the server
1640
+ * process to amortise the verifyAccess + bootstrapWikiFiles overhead.
1641
+ * The cache entry is evicted if storage is found to be empty on a
1642
+ * subsequent request (e.g. after an external data wipe), so bootstrap
1643
+ * re-runs automatically without needing a redeploy.
1644
+ *
1645
+ * Note: when the storage backend is Supabase the service-role key is used
1646
+ * for all operations, which bypasses Supabase Storage RLS by design.
1647
+ * App-level path isolation is the primary enforcement mechanism.
1648
+ * Supabase Storage RLS policies (see scripts/supabase-rls-policies.sql)
1649
+ * provide defence-in-depth for direct API / JWT-based access.
1650
+ */
1651
+ async function getOrCreateScopedClient(subjectKey, displaySubject) {
1652
+ const cached = scopedDriveCache.get(subjectKey);
1653
+ if (cached) {
1654
+ const now = Date.now();
1655
+ if (now - cached.lastWipeCheckAt < SCOPED_DRIVE_WIPE_CHECK_TTL_MS) {
1656
+ // Within TTL — return the cached client without an expensive listing.
1657
+ return cached.client;
1658
+ }
1659
+ // TTL expired — probe for a single marker file to detect an external
1660
+ // wipe without listing the whole wiki. Uses an existence check rather
1661
+ // than a read: index.md is ~200 KB on a large wiki, and downloading it
1662
+ // every 5 minutes per user to answer a yes/no question is pure waste
1663
+ // (GH #412).
1664
+ const scoped = await cached.client;
1665
+ const wipeDetected = await scoped.fileExists("index.md")
1666
+ .then((exists) => !exists)
1667
+ .catch(() => false);
1668
+ if (!wipeDetected) {
1669
+ cached.lastWipeCheckAt = now;
1670
+ return scoped;
1671
+ }
1672
+ // Storage was wiped — evict and fall through to re-bootstrap below.
1673
+ scopedDriveCache.delete(subjectKey);
1674
+ }
1675
+ const createDrive = (async () => {
1676
+ const scoped = createScopedStorageClient(drive, `users/${subjectKey}`);
1677
+ await scoped.verifyAccess();
1678
+ await scoped.bootstrapWikiFiles();
1679
+ // Backfill the Postgres index in the background — non-blocking.
1680
+ backfillIndexForUser(scoped, displaySubject).catch(() => { });
1681
+ return scoped;
1682
+ })();
1683
+ scopedDriveCache.set(subjectKey, { client: createDrive, lastWipeCheckAt: Date.now() });
1684
+ try {
1685
+ return await createDrive;
1686
+ }
1687
+ catch (err) {
1688
+ scopedDriveCache.delete(subjectKey);
1689
+ throw err;
1690
+ }
1691
+ }
1692
+ /**
1693
+ * Cross-account project sharing (BRA-368): when the authenticated user has
1694
+ * one or more active grants (see src/access/projectShares.ts), wraps their
1695
+ * own scoped client so page paths under a granted project route to the
1696
+ * grant owner's storage namespace instead. Transparent to the MCP tool
1697
+ * layer — read_page/patch_page/etc. see one StorageClient either way.
1698
+ */
1699
+ async function resolveDriveForAuthorization(auth) {
1700
+ if (!auth || !shouldUseUserScopedStorage(auth.mode)) {
1701
+ // api-key and connector-key modes fall through to the shared global drive.
1702
+ return drive;
1703
+ }
1704
+ const subjectKey = sanitizeSubjectForPath(auth.subject);
1705
+ const ownScoped = await getOrCreateScopedClient(subjectKey, auth.subject);
1706
+ const grants = await listActiveGrantsForGrantee(auth.subject).catch((err) => {
1707
+ console.warn("[project-shares] failed to list grants:", err instanceof Error ? err.message : err);
1708
+ return [];
1709
+ });
1710
+ if (grants.length === 0)
1711
+ return ownScoped;
1712
+ const routes = await Promise.all(grants.map(async (g) => {
1713
+ const ownerKey = sanitizeSubjectForPath(g.owner_subject);
1714
+ const ownerScoped = await getOrCreateScopedClient(ownerKey, g.owner_subject);
1715
+ return { prefix: g.project_path, client: ownerScoped, ownerSubject: g.owner_subject };
1716
+ }));
1717
+ return new GrantAwareStorageClient(ownScoped, routes, auth.subject);
1718
+ }
1719
+ const server = http.createServer(async (req, res) => {
1720
+ const startTime = Date.now();
1721
+ const method = req.method ?? "GET";
1722
+ const parsedUrl = new URL(req.url ?? "/", "http://localhost");
1723
+ const pathname = parsedUrl.pathname;
1724
+ // Inject X-Response-Time header before headers are flushed.
1725
+ const _origWriteHead = res.writeHead.bind(res);
1726
+ res.writeHead = function (...args) {
1727
+ if (!res.headersSent) {
1728
+ res.setHeader("X-Response-Time", `${Date.now() - startTime}ms`);
1729
+ }
1730
+ return _origWriteHead(...args);
1731
+ };
1732
+ // Emit one log line per request after the response is fully sent.
1733
+ res.on("finish", () => {
1734
+ const durationMs = Date.now() - startTime;
1735
+ const extras = res._logExtras ?? {};
1736
+ emitRequestLog(method, pathname, res.statusCode, durationMs, extras);
1737
+ // Fire-and-forget insert into Supabase request_events table.
1738
+ const supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
1739
+ const serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
1740
+ if (supabaseUrl && serviceRoleKey) {
1741
+ void insertRequestEvent(supabaseUrl, serviceRoleKey, {
1742
+ method,
1743
+ path: pathname,
1744
+ status: res.statusCode,
1745
+ duration_ms: durationMs,
1746
+ backend: extras.backend,
1747
+ query_ms: extras.queryMs,
1748
+ sync_ms: extras.syncMs,
1749
+ result_count: extras.resultCount,
1750
+ query_text: extras.queryText,
1751
+ tool: extras.tool,
1752
+ page_count: extras.pageCount,
1753
+ }).catch((err) => {
1754
+ console.error("[metrics] insert failed", err.message);
1755
+ });
1756
+ }
1757
+ });
1758
+ try {
1759
+ let authorization = null;
1760
+ if (method === "GET" && pathname === "/openapi.json") {
1761
+ json(req, res, 200, openApi);
1762
+ return;
1763
+ }
1764
+ if (method === "GET" && pathname === "/.well-known/openapi.json") {
1765
+ json(req, res, 200, openApi);
1766
+ return;
1767
+ }
1768
+ if (method === "GET" && pathname === "/api/v1") {
1769
+ json(req, res, 200, openApi);
1770
+ return;
1771
+ }
1772
+ if (method === "GET" && pathname === "/.well-known/oauth-authorization-server") {
1773
+ json(req, res, 200, oauthAuthorizationServerMetadata());
1774
+ return;
1775
+ }
1776
+ if (method === "GET" && pathname === "/.well-known/oauth-protected-resource") {
1777
+ json(req, res, 200, oauthProtectedResourceMetadata());
1778
+ return;
1779
+ }
1780
+ if (method === "GET" && pathname.startsWith(CSS_ASSET_PREFIX)) {
1781
+ await cssAsset(res, pathname);
1782
+ return;
1783
+ }
1784
+ if (method === "GET" && pathname.startsWith(IMG_ASSET_PREFIX)) {
1785
+ await imgAsset(res, pathname);
1786
+ return;
1787
+ }
1788
+ // ── Editor SPA ──────────────────────────────────────────────────────────
1789
+ if (method === "GET" && pathname === EDITOR_PREFIX) {
1790
+ res.writeHead(302, { location: `${EDITOR_PREFIX}/` });
1791
+ res.end();
1792
+ return;
1793
+ }
1794
+ if (method === "GET" && pathname.startsWith(`${EDITOR_PREFIX}/`)) {
1795
+ const relativePath = pathname.slice(EDITOR_PREFIX.length + 1); // strip /editor/
1796
+ const filePath = relativePath
1797
+ ? path.resolve(EDITOR_DIST, ...relativePath.split("/").filter(Boolean))
1798
+ : path.join(EDITOR_DIST, "index.html");
1799
+ // Path traversal guard
1800
+ if (!filePath.startsWith(EDITOR_DIST + path.sep) && filePath !== EDITOR_DIST) {
1801
+ res.writeHead(400, { "content-type": "text/plain" });
1802
+ res.end("Bad request");
1803
+ return;
1804
+ }
1805
+ await serveEditorAsset(res, filePath);
1806
+ return;
1807
+ }
1808
+ if (enableRemoteMcp && pathname === "/mcp") {
1809
+ const mcpAuthStart = Date.now();
1810
+ authorization = await authorizeRequest(req, parsedUrl, oauth, supabaseAuth, patStore);
1811
+ const mcpAuthMs = Date.now() - mcpAuthStart;
1812
+ if (!authorization) {
1813
+ unauthorized(res, `${publicBase()}/.well-known/oauth-protected-resource`);
1814
+ return;
1815
+ }
1816
+ // MCP can invoke both read and write tools, so require write scope up front.
1817
+ if (!hasRequiredScopes(authorization.scopes, ["wiki.write"])) {
1818
+ forbidden(res, ["wiki.write"]);
1819
+ return;
1820
+ }
1821
+ if (method === "POST") {
1822
+ const body = await readJsonBody(req);
1823
+ const sessionIdHeader = req.headers["mcp-session-id"];
1824
+ const sessionId = typeof sessionIdHeader === "string" ? sessionIdHeader : undefined;
1825
+ // Log the JSON-RPC method for diagnostics.
1826
+ const bodyMsg = Array.isArray(body) ? body[0] : body;
1827
+ const rpcMethod = typeof bodyMsg === "object" && bodyMsg !== null
1828
+ ? String(bodyMsg.method ?? "unknown")
1829
+ : "unknown";
1830
+ const rpcId = typeof bodyMsg === "object" && bodyMsg !== null
1831
+ ? bodyMsg.id ?? null
1832
+ : null;
1833
+ console.error(`[brains] MCP rpc=${rpcMethod} session=${sessionId ?? "none"} authMs=${mcpAuthMs}`);
1834
+ let existing = sessionId ? mcpSessions.get(sessionId) : undefined;
1835
+ if (!existing) {
1836
+ if (rpcMethod !== "initialize") {
1837
+ if (!sessionId) {
1838
+ // No session ID at all — client must send initialize first.
1839
+ json(req, res, 400, {
1840
+ jsonrpc: "2.0",
1841
+ error: {
1842
+ code: -32000,
1843
+ message: "No active session. Send an initialize request first.",
1844
+ },
1845
+ id: rpcId,
1846
+ });
1847
+ return;
1848
+ }
1849
+ // Stale session ID (e.g. after a server redeploy) with valid auth.
1850
+ // Transparently spin up a new session using the same session ID so
1851
+ // the client never sees a -32000 error and never discards its OAuth
1852
+ // tokens. The synthetic initialize primes the transport's internal
1853
+ // state before we dispatch the client's actual request.
1854
+ //
1855
+ // Use mcpReconnectPromises to deduplicate concurrent requests that arrive
1856
+ // with the same stale session ID before the first reconnect completes.
1857
+ const staleSid = sessionId;
1858
+ let reconnectPromise = mcpReconnectPromises.get(staleSid);
1859
+ if (!reconnectPromise) {
1860
+ console.error(`[brains] MCP stale session=${staleSid} rpc=${rpcMethod} — transparent reconnect`);
1861
+ reconnectPromise = (async () => {
1862
+ const mcpUserId = shouldUseUserScopedStorage(authorization.mode) ? authorization.subject : "";
1863
+ const mcpUserAccessLevel = await getUserAccessLevel(mcpUserId);
1864
+ // Re-resolve the drive on every tool call rather than baking in a
1865
+ // snapshot here — otherwise a project-share grant revoked mid-session
1866
+ // keeps being honored for the lifetime of this MCP session (BRA-377).
1867
+ const mcpServer = createBrainsServer(() => resolveDriveForAuthorization(authorization), mcpUserId, mcpUserAccessLevel);
1868
+ const transport = new StreamableHTTPServerTransport({
1869
+ sessionIdGenerator: () => staleSid,
1870
+ });
1871
+ transport.onclose = () => {
1872
+ mcpSessions.delete(staleSid);
1873
+ };
1874
+ await mcpServer.connect(transport);
1875
+ const ok = await syntheticMcpInit(transport);
1876
+ if (!ok) {
1877
+ return null;
1878
+ }
1879
+ const entry = { server: mcpServer, transport };
1880
+ mcpSessions.set(staleSid, entry);
1881
+ return entry;
1882
+ })().finally(() => {
1883
+ mcpReconnectPromises.delete(staleSid);
1884
+ });
1885
+ mcpReconnectPromises.set(staleSid, reconnectPromise);
1886
+ }
1887
+ const reconnected = await reconnectPromise;
1888
+ if (!reconnected) {
1889
+ json(req, res, 400, {
1890
+ jsonrpc: "2.0",
1891
+ error: {
1892
+ code: -32000,
1893
+ message: "No active session. Send an initialize request first.",
1894
+ },
1895
+ id: rpcId,
1896
+ });
1897
+ return;
1898
+ }
1899
+ existing = reconnected;
1900
+ }
1901
+ else {
1902
+ const mcpUserId = shouldUseUserScopedStorage(authorization.mode) ? authorization.subject : "";
1903
+ const mcpUserAccessLevel = await getUserAccessLevel(mcpUserId);
1904
+ // Re-resolve the drive on every tool call rather than baking in a
1905
+ // snapshot here — otherwise a project-share grant revoked mid-session
1906
+ // keeps being honored for the lifetime of this MCP session (BRA-377).
1907
+ const mcpServer = createBrainsServer(() => resolveDriveForAuthorization(authorization), mcpUserId, mcpUserAccessLevel);
1908
+ const transport = new StreamableHTTPServerTransport({
1909
+ sessionIdGenerator: () => randomUUID(),
1910
+ });
1911
+ transport.onclose = () => {
1912
+ const activeSessionId = transport.sessionId;
1913
+ if (activeSessionId) {
1914
+ mcpSessions.delete(activeSessionId);
1915
+ }
1916
+ };
1917
+ await mcpServer.connect(transport);
1918
+ existing = { server: mcpServer, transport };
1919
+ }
1920
+ }
1921
+ const { transport } = existing;
1922
+ const mcpHandlerStart = Date.now();
1923
+ // Inject timing headers before the transport writes its response headers.
1924
+ res.setHeader("X-Brains-Auth-Ms", String(mcpAuthMs));
1925
+ res.setHeader("X-Brains-Handler-Start-Ms", String(mcpHandlerStart));
1926
+ try {
1927
+ await transport.handleRequest(req, res, body);
1928
+ }
1929
+ catch (mcpErr) {
1930
+ console.error("[brains] MCP transport error:", mcpErr instanceof Error ? mcpErr.stack : String(mcpErr));
1931
+ throw mcpErr;
1932
+ }
1933
+ if (!res.headersSent) {
1934
+ res.setHeader("X-Brains-Handler-End-Ms", String(Date.now()));
1935
+ }
1936
+ const newSessionId = transport.sessionId;
1937
+ if (newSessionId && !mcpSessions.has(newSessionId)) {
1938
+ mcpSessions.set(newSessionId, existing);
1939
+ }
1940
+ return;
1941
+ }
1942
+ if (method === "GET" || method === "DELETE") {
1943
+ const sessionIdHeader = req.headers["mcp-session-id"];
1944
+ const sessionId = typeof sessionIdHeader === "string" ? sessionIdHeader : undefined;
1945
+ if (!sessionId) {
1946
+ json(req, res, 400, {
1947
+ jsonrpc: "2.0",
1948
+ error: {
1949
+ code: -32000,
1950
+ message: "Bad Request: Mcp-Session-Id header is required.",
1951
+ },
1952
+ id: null,
1953
+ });
1954
+ return;
1955
+ }
1956
+ const existing = mcpSessions.get(sessionId);
1957
+ if (!existing) {
1958
+ json(req, res, 404, {
1959
+ jsonrpc: "2.0",
1960
+ error: {
1961
+ code: -32001,
1962
+ message: "Session not found.",
1963
+ },
1964
+ id: null,
1965
+ });
1966
+ return;
1967
+ }
1968
+ const { transport } = existing;
1969
+ await transport.handleRequest(req, res);
1970
+ return;
1971
+ }
1972
+ }
1973
+ if (pathname === "/oauth/authorize") {
1974
+ if (!oauth.isEnabled()) {
1975
+ json(req, res, 503, {
1976
+ ok: false,
1977
+ error: "OAuth is not configured",
1978
+ detail: "Set BRAINS_OAUTH_CLIENTS and either BRAINS_OAUTH_PASSWORD or SUPABASE_URL + SUPABASE_ANON_KEY",
1979
+ });
1980
+ return;
1981
+ }
1982
+ if (method === "GET") {
1983
+ const parsed = oauthAuthorizeQuerySchema.parse({
1984
+ response_type: parsedUrl.searchParams.get("response_type"),
1985
+ client_id: parsedUrl.searchParams.get("client_id"),
1986
+ redirect_uri: parsedUrl.searchParams.get("redirect_uri"),
1987
+ scope: parsedUrl.searchParams.get("scope") ?? undefined,
1988
+ state: parsedUrl.searchParams.get("state") ?? undefined,
1989
+ code_challenge: parsedUrl.searchParams.get("code_challenge") ?? undefined,
1990
+ code_challenge_method: parsedUrl.searchParams.get("code_challenge_method") ?? undefined,
1991
+ });
1992
+ const validated = await oauth.validateAuthorizeRequest({
1993
+ responseType: parsed.response_type,
1994
+ clientId: parsed.client_id,
1995
+ redirectUri: parsed.redirect_uri,
1996
+ scope: parsed.scope,
1997
+ state: parsed.state,
1998
+ codeChallenge: parsed.code_challenge,
1999
+ codeChallengeMethod: parsed.code_challenge_method,
2000
+ });
2001
+ const web = webUrl();
2002
+ if (web) {
2003
+ const consentUrl = new URL("/oauth/consent", web);
2004
+ consentUrl.searchParams.set("client_id", validated.client.clientId);
2005
+ consentUrl.searchParams.set("redirect_uri", parsed.redirect_uri);
2006
+ consentUrl.searchParams.set("scope", validated.scope.join(" "));
2007
+ if (parsed.state)
2008
+ consentUrl.searchParams.set("state", parsed.state);
2009
+ if (parsed.code_challenge)
2010
+ consentUrl.searchParams.set("code_challenge", parsed.code_challenge);
2011
+ if (parsed.code_challenge_method)
2012
+ consentUrl.searchParams.set("code_challenge_method", parsed.code_challenge_method);
2013
+ res.writeHead(302, { location: consentUrl.toString() });
2014
+ res.end();
2015
+ }
2016
+ else {
2017
+ html(req, res, 200, oauthAuthorizeFormHtml({
2018
+ clientId: validated.client.clientId,
2019
+ redirectUri: parsed.redirect_uri,
2020
+ scope: validated.scope.join(" "),
2021
+ state: parsed.state ?? "",
2022
+ codeChallenge: parsed.code_challenge ?? "",
2023
+ codeChallengeMethod: parsed.code_challenge_method ?? "",
2024
+ }));
2025
+ }
2026
+ return;
2027
+ }
2028
+ if (method === "POST") {
2029
+ const form = await readFormBody(req);
2030
+ const parsed = oauthAuthorizeFormSchema.parse(form);
2031
+ const validated = await oauth.validateAuthorizeRequest({
2032
+ responseType: parsed.response_type,
2033
+ clientId: parsed.client_id,
2034
+ redirectUri: parsed.redirect_uri,
2035
+ scope: parsed.scope,
2036
+ state: parsed.state,
2037
+ codeChallenge: parsed.code_challenge,
2038
+ codeChallengeMethod: parsed.code_challenge_method,
2039
+ });
2040
+ const loginSubject = await oauth.verifyLogin(parsed.username, parsed.password);
2041
+ if (!loginSubject) {
2042
+ const web = webUrl();
2043
+ if (web) {
2044
+ const consentUrl = new URL("/oauth/consent", web);
2045
+ consentUrl.searchParams.set("client_id", parsed.client_id);
2046
+ consentUrl.searchParams.set("redirect_uri", parsed.redirect_uri);
2047
+ if (parsed.scope)
2048
+ consentUrl.searchParams.set("scope", parsed.scope);
2049
+ if (parsed.state)
2050
+ consentUrl.searchParams.set("state", parsed.state);
2051
+ if (parsed.code_challenge)
2052
+ consentUrl.searchParams.set("code_challenge", parsed.code_challenge);
2053
+ if (parsed.code_challenge_method)
2054
+ consentUrl.searchParams.set("code_challenge_method", parsed.code_challenge_method);
2055
+ consentUrl.searchParams.set("error", "Invalid email or password.");
2056
+ res.writeHead(302, { location: consentUrl.toString() });
2057
+ res.end();
2058
+ }
2059
+ else {
2060
+ json(req, res, 401, { ok: false, error: "Invalid login" });
2061
+ }
2062
+ return;
2063
+ }
2064
+ const code = await oauth.issueAuthorizationCode({
2065
+ clientId: validated.client.clientId,
2066
+ redirectUri: parsed.redirect_uri,
2067
+ scope: validated.scope,
2068
+ subject: loginSubject,
2069
+ codeChallenge: parsed.code_challenge,
2070
+ codeChallengeMethod: parsed.code_challenge_method,
2071
+ });
2072
+ // Build the full redirect URI with code + state params, then show a
2073
+ // success page that auto-redirects (handles desktop/mobile deep links
2074
+ // like obsidian:// where a bare 302 leaves the browser on a blank page).
2075
+ const callbackUrl = new URL(parsed.redirect_uri);
2076
+ callbackUrl.searchParams.set("code", code);
2077
+ if (parsed.state)
2078
+ callbackUrl.searchParams.set("state", parsed.state);
2079
+ html(req, res, 200, oauthSuccessHtml({ redirectUri: callbackUrl.toString() }));
2080
+ return;
2081
+ }
2082
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2083
+ return;
2084
+ }
2085
+ if (pathname === "/oauth/token") {
2086
+ if (!oauth.isEnabled()) {
2087
+ json(req, res, 503, {
2088
+ ok: false,
2089
+ error: "OAuth is not configured",
2090
+ detail: "Set BRAINS_OAUTH_CLIENTS and either BRAINS_OAUTH_PASSWORD or SUPABASE_URL + SUPABASE_ANON_KEY",
2091
+ });
2092
+ return;
2093
+ }
2094
+ if (method !== "POST") {
2095
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2096
+ return;
2097
+ }
2098
+ const form = oauthTokenFormSchema.parse(await readFormBody(req));
2099
+ const basic = parseBasicClientCredentials(req);
2100
+ const clientId = form.client_id ?? basic?.clientId;
2101
+ const clientSecret = form.client_secret ?? basic?.clientSecret;
2102
+ if (!clientId) {
2103
+ json(req, res, 400, {
2104
+ error: "invalid_client",
2105
+ error_description: "client_id is required",
2106
+ });
2107
+ return;
2108
+ }
2109
+ if (form.grant_type === "authorization_code") {
2110
+ if (!form.code || !form.redirect_uri) {
2111
+ json(req, res, 400, {
2112
+ error: "invalid_request",
2113
+ error_description: "code and redirect_uri are required",
2114
+ });
2115
+ return;
2116
+ }
2117
+ const token = await oauth.exchangeAuthorizationCode({
2118
+ code: form.code,
2119
+ clientId,
2120
+ clientSecret,
2121
+ redirectUri: form.redirect_uri,
2122
+ codeVerifier: form.code_verifier,
2123
+ });
2124
+ json(req, res, 200, {
2125
+ token_type: token.tokenType,
2126
+ access_token: token.accessToken,
2127
+ expires_in: token.expiresIn,
2128
+ refresh_token: token.refreshToken,
2129
+ scope: token.scope,
2130
+ });
2131
+ return;
2132
+ }
2133
+ if (!form.refresh_token) {
2134
+ json(req, res, 400, {
2135
+ error: "invalid_request",
2136
+ error_description: "refresh_token is required",
2137
+ });
2138
+ return;
2139
+ }
2140
+ const token = await oauth.refreshAccessToken({
2141
+ refreshToken: form.refresh_token,
2142
+ clientId,
2143
+ clientSecret,
2144
+ });
2145
+ json(req, res, 200, {
2146
+ token_type: token.tokenType,
2147
+ access_token: token.accessToken,
2148
+ expires_in: token.expiresIn,
2149
+ refresh_token: token.refreshToken,
2150
+ scope: token.scope,
2151
+ });
2152
+ return;
2153
+ }
2154
+ if (pathname === "/oauth/register") {
2155
+ if (!oauth.hasLoginConfig()) {
2156
+ json(req, res, 503, { error: "invalid_client_metadata", error_description: "OAuth is not configured" });
2157
+ return;
2158
+ }
2159
+ if (method !== "POST") {
2160
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2161
+ return;
2162
+ }
2163
+ const body = await readJsonBody(req);
2164
+ const redirectUris = body?.redirect_uris;
2165
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
2166
+ json(req, res, 400, { error: "invalid_redirect_uri", error_description: "redirect_uris is required" });
2167
+ return;
2168
+ }
2169
+ try {
2170
+ const clientInfo = await oauth.registerDynamicClient({
2171
+ redirectUris: redirectUris,
2172
+ clientName: typeof body?.client_name === "string"
2173
+ ? body.client_name
2174
+ : undefined,
2175
+ });
2176
+ json(req, res, 201, {
2177
+ client_id: clientInfo.clientId,
2178
+ client_secret_expires_at: 0,
2179
+ redirect_uris: clientInfo.redirectUris,
2180
+ token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
2181
+ grant_types: clientInfo.grantTypes,
2182
+ response_types: clientInfo.responseTypes,
2183
+ scope: clientInfo.scope,
2184
+ });
2185
+ }
2186
+ catch (e) {
2187
+ const msg = e instanceof Error ? e.message : "Registration failed";
2188
+ json(req, res, 400, { error: "invalid_redirect_uri", error_description: msg });
2189
+ }
2190
+ return;
2191
+ }
2192
+ if (pathname === "/oauth/revoke") {
2193
+ if (!oauth.isEnabled()) {
2194
+ json(req, res, 503, {
2195
+ ok: false,
2196
+ error: "OAuth is not configured",
2197
+ detail: "Set BRAINS_OAUTH_CLIENTS and either BRAINS_OAUTH_PASSWORD or SUPABASE_URL + SUPABASE_ANON_KEY",
2198
+ });
2199
+ return;
2200
+ }
2201
+ if (method !== "POST") {
2202
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2203
+ return;
2204
+ }
2205
+ const form = oauthRevokeFormSchema.parse(await readFormBody(req));
2206
+ const basic = parseBasicClientCredentials(req);
2207
+ const clientId = form.client_id ?? basic?.clientId;
2208
+ const clientSecret = form.client_secret ?? basic?.clientSecret;
2209
+ if (!clientId) {
2210
+ json(req, res, 400, {
2211
+ error: "invalid_client",
2212
+ error_description: "client_id is required",
2213
+ });
2214
+ return;
2215
+ }
2216
+ try {
2217
+ await oauth.revokeToken({
2218
+ token: form.token,
2219
+ tokenTypeHint: form.token_type_hint,
2220
+ clientId,
2221
+ clientSecret,
2222
+ });
2223
+ }
2224
+ catch (error) {
2225
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
2226
+ if (message.includes("unknown client_id")
2227
+ || message.includes("invalid client credentials")
2228
+ || message.includes("invalid client")) {
2229
+ const status = req.headers.authorization?.startsWith("Basic ") ? 401 : 400;
2230
+ const headers = {};
2231
+ if (status === 401) {
2232
+ headers["www-authenticate"] = 'Basic realm="oauth"';
2233
+ }
2234
+ json(req, res, status, {
2235
+ error: "invalid_client",
2236
+ error_description: "Client authentication failed",
2237
+ }, headers);
2238
+ return;
2239
+ }
2240
+ if (message.includes("unknown token")
2241
+ || message.includes("invalid token")
2242
+ || message.includes("token not found")) {
2243
+ // RFC 7009 §2.2: respond with 200 and empty body for unknown/invalid tokens
2244
+ res.writeHead(200, {
2245
+ "content-type": "application/json; charset=utf-8",
2246
+ "content-length": "2",
2247
+ "cache-control": "no-store",
2248
+ });
2249
+ res.end("{}");
2250
+ return;
2251
+ }
2252
+ if (message.includes("unsupported token type")) {
2253
+ json(req, res, 400, {
2254
+ error: "unsupported_token_type",
2255
+ error_description: "The authorization server does not support the revocation of the presented token type",
2256
+ });
2257
+ return;
2258
+ }
2259
+ throw error;
2260
+ }
2261
+ // RFC 7009 §2.2: respond with 200 and empty body on success
2262
+ res.writeHead(200, {
2263
+ "content-type": "application/json; charset=utf-8",
2264
+ "content-length": "2",
2265
+ "cache-control": "no-store",
2266
+ });
2267
+ res.end("{}");
2268
+ return;
2269
+ }
2270
+ if (pathname === "/oauth/signup") {
2271
+ if (!oauth.isEnabled()) {
2272
+ json(req, res, 503, {
2273
+ ok: false,
2274
+ error: "OAuth is not configured",
2275
+ detail: "Set BRAINS_OAUTH_CLIENTS and SUPABASE_URL + SUPABASE_ANON_KEY",
2276
+ });
2277
+ return;
2278
+ }
2279
+ if (method !== "POST") {
2280
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2281
+ return;
2282
+ }
2283
+ const form = await readFormBody(req);
2284
+ const parsed = oauthSignupFormSchema.safeParse(form);
2285
+ if (!parsed.success) {
2286
+ const firstIssue = parsed.error.issues[0]?.message ?? "Invalid form data";
2287
+ const displayError = firstIssue.toLowerCase().includes("email") ? "Enter a valid email address." : firstIssue;
2288
+ const web = webUrl();
2289
+ if (web) {
2290
+ const consentUrl = new URL("/oauth/consent", web);
2291
+ consentUrl.searchParams.set("client_id", form.client_id ?? "");
2292
+ consentUrl.searchParams.set("redirect_uri", form.redirect_uri ?? "");
2293
+ if (form.scope)
2294
+ consentUrl.searchParams.set("scope", form.scope);
2295
+ if (form.state)
2296
+ consentUrl.searchParams.set("state", form.state);
2297
+ if (form.code_challenge)
2298
+ consentUrl.searchParams.set("code_challenge", form.code_challenge);
2299
+ if (form.code_challenge_method)
2300
+ consentUrl.searchParams.set("code_challenge_method", form.code_challenge_method);
2301
+ consentUrl.searchParams.set("tab", "signup");
2302
+ consentUrl.searchParams.set("error", displayError);
2303
+ res.writeHead(302, { location: consentUrl.toString() });
2304
+ res.end();
2305
+ }
2306
+ else {
2307
+ html(req, res, 400, oauthAuthorizeFormHtml({
2308
+ clientId: form.client_id ?? "",
2309
+ redirectUri: form.redirect_uri ?? "",
2310
+ scope: form.scope ?? "",
2311
+ state: form.state ?? "",
2312
+ codeChallenge: form.code_challenge ?? "",
2313
+ codeChallengeMethod: form.code_challenge_method ?? "",
2314
+ tab: "signup",
2315
+ error: displayError,
2316
+ }));
2317
+ }
2318
+ return;
2319
+ }
2320
+ if (parsed.data.password !== parsed.data.confirm_password) {
2321
+ const web = webUrl();
2322
+ if (web) {
2323
+ const consentUrl = new URL("/oauth/consent", web);
2324
+ consentUrl.searchParams.set("client_id", parsed.data.client_id);
2325
+ consentUrl.searchParams.set("redirect_uri", parsed.data.redirect_uri);
2326
+ if (parsed.data.scope)
2327
+ consentUrl.searchParams.set("scope", parsed.data.scope);
2328
+ if (parsed.data.state)
2329
+ consentUrl.searchParams.set("state", parsed.data.state);
2330
+ if (parsed.data.code_challenge)
2331
+ consentUrl.searchParams.set("code_challenge", parsed.data.code_challenge);
2332
+ if (parsed.data.code_challenge_method)
2333
+ consentUrl.searchParams.set("code_challenge_method", parsed.data.code_challenge_method);
2334
+ consentUrl.searchParams.set("tab", "signup");
2335
+ consentUrl.searchParams.set("error", "Passwords do not match.");
2336
+ res.writeHead(302, { location: consentUrl.toString() });
2337
+ res.end();
2338
+ }
2339
+ else {
2340
+ html(req, res, 400, oauthAuthorizeFormHtml({
2341
+ clientId: parsed.data.client_id,
2342
+ redirectUri: parsed.data.redirect_uri,
2343
+ scope: parsed.data.scope ?? "",
2344
+ state: parsed.data.state ?? "",
2345
+ codeChallenge: parsed.data.code_challenge ?? "",
2346
+ codeChallengeMethod: parsed.data.code_challenge_method ?? "",
2347
+ tab: "signup",
2348
+ error: "Passwords do not match.",
2349
+ }));
2350
+ }
2351
+ return;
2352
+ }
2353
+ // Build a URL back to the consent page for use in two places:
2354
+ // 1. Supabase email redirect_to — where Supabase sends the user after email verification.
2355
+ // Must be in Supabase's allowed-URL list. Use the web domain (www.usebrains.app) which
2356
+ // is more likely to be allowlisted than the API domain (lets.usebrains.app).
2357
+ // 2. The "Back to sign in" link on the confirm-email page.
2358
+ // Falls back to the API authorize URL if WEB_URL is not set.
2359
+ const web = webUrl();
2360
+ // Extract parsed fields to locals so TypeScript narrowing works inside closures.
2361
+ const sd = parsed.data;
2362
+ const addOAuthParams = (u) => {
2363
+ u.searchParams.set("response_type", "code");
2364
+ u.searchParams.set("client_id", sd.client_id);
2365
+ u.searchParams.set("redirect_uri", sd.redirect_uri);
2366
+ if (sd.scope)
2367
+ u.searchParams.set("scope", sd.scope);
2368
+ if (sd.state)
2369
+ u.searchParams.set("state", sd.state);
2370
+ if (sd.code_challenge)
2371
+ u.searchParams.set("code_challenge", sd.code_challenge);
2372
+ if (sd.code_challenge_method)
2373
+ u.searchParams.set("code_challenge_method", sd.code_challenge_method);
2374
+ };
2375
+ const makeConsentBase = () => web ? new URL("/oauth/consent", web) : new URL("/oauth/authorize", publicBase());
2376
+ const emailRedirectUrl = (() => { const u = makeConsentBase(); addOAuthParams(u); u.searchParams.set("verified", "true"); return u.toString(); })();
2377
+ const backPath = (() => { const u = makeConsentBase(); addOAuthParams(u); return u.pathname + u.search; })();
2378
+ const result = await oauth.signUpWithEmail(parsed.data.email, parsed.data.password, emailRedirectUrl);
2379
+ if (!result.ok) {
2380
+ if (web) {
2381
+ const consentUrl = new URL("/oauth/consent", web);
2382
+ consentUrl.searchParams.set("client_id", parsed.data.client_id);
2383
+ consentUrl.searchParams.set("redirect_uri", parsed.data.redirect_uri);
2384
+ if (parsed.data.scope)
2385
+ consentUrl.searchParams.set("scope", parsed.data.scope);
2386
+ if (parsed.data.state)
2387
+ consentUrl.searchParams.set("state", parsed.data.state);
2388
+ if (parsed.data.code_challenge)
2389
+ consentUrl.searchParams.set("code_challenge", parsed.data.code_challenge);
2390
+ if (parsed.data.code_challenge_method)
2391
+ consentUrl.searchParams.set("code_challenge_method", parsed.data.code_challenge_method);
2392
+ consentUrl.searchParams.set("tab", "signup");
2393
+ consentUrl.searchParams.set("error", result.error);
2394
+ res.writeHead(302, { location: consentUrl.toString() });
2395
+ res.end();
2396
+ }
2397
+ else {
2398
+ html(req, res, 400, oauthAuthorizeFormHtml({
2399
+ clientId: parsed.data.client_id,
2400
+ redirectUri: parsed.data.redirect_uri,
2401
+ scope: parsed.data.scope ?? "",
2402
+ state: parsed.data.state ?? "",
2403
+ codeChallenge: parsed.data.code_challenge ?? "",
2404
+ codeChallengeMethod: parsed.data.code_challenge_method ?? "",
2405
+ tab: "signup",
2406
+ error: result.error,
2407
+ }));
2408
+ }
2409
+ return;
2410
+ }
2411
+ if (web) {
2412
+ const confirmUrl = new URL("/oauth/confirm-email", web);
2413
+ confirmUrl.searchParams.set("email", parsed.data.email);
2414
+ confirmUrl.searchParams.set("back", backPath);
2415
+ res.writeHead(302, { location: confirmUrl.toString() });
2416
+ res.end();
2417
+ }
2418
+ else {
2419
+ html(req, res, 200, signupConfirmHtml({ email: parsed.data.email, backPath }));
2420
+ }
2421
+ return;
2422
+ }
2423
+ if (!isPublicRoute(method, pathname)) {
2424
+ authorization = await authorizeRequest(req, parsedUrl, oauth, supabaseAuth, patStore);
2425
+ if (!authorization) {
2426
+ unauthorized(res, `${publicBase()}/.well-known/oauth-protected-resource`);
2427
+ return;
2428
+ }
2429
+ const writeRoute = (method === "POST" && pathname === "/api/v1/pages") ||
2430
+ (method === "POST" && pathname === "/api/v1/pages/batch") ||
2431
+ (method === "PUT" && pathname === "/api/v1/page") ||
2432
+ (method === "PATCH" && pathname === "/api/v1/page") ||
2433
+ (method === "DELETE" && pathname === "/api/v1/page") ||
2434
+ (method === "POST" && pathname === "/api/v1/page/move") ||
2435
+ (method === "POST" && pathname === "/api/v1/audit") ||
2436
+ (method === "POST" && pathname === "/api/v1/pages/delete-batch") ||
2437
+ (method === "POST" && pathname === "/api/v1/logs") ||
2438
+ (method === "POST" && pathname === "/api/v1/import") ||
2439
+ (method === "POST" && pathname === "/api/v1/export") ||
2440
+ (method === "POST" && pathname === "/api/v1/import-zip") ||
2441
+ (method === "POST" && pathname === "/api/v1/import-zip/upload") ||
2442
+ (method === "POST" && pathname === "/api/v1/purge") ||
2443
+ (method === "POST" && pathname === "/api/v1/links/extract") ||
2444
+ (method === "POST" && pathname === "/api/v1/session/bundle") ||
2445
+ (method === "POST" && pathname.startsWith("/api/v1/tools/")) ||
2446
+ (method === "POST" && pathname === "/tool") ||
2447
+ (method === "POST" && pathname === "/chat") ||
2448
+ (method === "POST" && pathname === "/admin/seed-users") ||
2449
+ (method === "DELETE" && pathname === "/admin/seed-users") ||
2450
+ (method === "DELETE" && pathname === "/api/v1/account");
2451
+ const requiredScopes = writeRoute ? ["wiki.write"] : ["wiki.read"];
2452
+ if (!hasRequiredScopes(authorization.scopes, requiredScopes)) {
2453
+ forbidden(res, requiredScopes);
2454
+ return;
2455
+ }
2456
+ }
2457
+ // ── Rate limiting ───────────────────────────────────────────────────
2458
+ if (RATE_LIMIT_ENABLED) {
2459
+ const rlClass = classifyRouteForRateLimit(method, pathname);
2460
+ if (rlClass !== null) {
2461
+ const subject = authorization?.subject ?? req.socket.remoteAddress ?? "unknown";
2462
+ const rateLimitRemaining = checkRateLimit(rateLimitWindows, `${rlClass}:${subject}`, RATE_LIMITS[rlClass], RATE_LIMIT_WINDOW_MS);
2463
+ if (rateLimitRemaining !== null) {
2464
+ const retryAfterSec = Math.max(1, Math.ceil(rateLimitRemaining / 1000));
2465
+ json(req, res, 429, { ok: false, error: "Rate limit exceeded", retryAfter: retryAfterSec }, { "Retry-After": String(retryAfterSec) });
2466
+ return;
2467
+ }
2468
+ }
2469
+ }
2470
+ const requestDrive = await resolveDriveForAuthorization(authorization);
2471
+ const requestUserId = authorization && shouldUseUserScopedStorage(authorization.mode)
2472
+ ? authorization.subject
2473
+ : "";
2474
+ const requestUserAccessLevel = await getUserAccessLevel(requestUserId);
2475
+ const requestAccessCtx = { userAccessLevel: requestUserAccessLevel };
2476
+ if (method === "GET" && pathname === "/") {
2477
+ json(req, res, 200, {
2478
+ ok: true,
2479
+ service: "brains",
2480
+ architecture: "api-first",
2481
+ endpoints: [
2482
+ "GET /cb",
2483
+ "GET /connect",
2484
+ "GET /openapi.json",
2485
+ "GET /.well-known/openapi.json",
2486
+ "GET /.well-known/oauth-authorization-server",
2487
+ "GET /.well-known/oauth-protected-resource",
2488
+ "GET /oauth/authorize",
2489
+ "POST /oauth/authorize",
2490
+ "POST /oauth/token",
2491
+ "POST /oauth/revoke",
2492
+ "GET /api/v1/health",
2493
+ "GET /api/v1/launch/status",
2494
+ "GET /api/v1/pages",
2495
+ "POST /api/v1/pages",
2496
+ "GET /api/v1/pages/{name}/backlinks",
2497
+ "GET /api/v1/pages/{name}/related",
2498
+ "GET /api/v1/pages/{name}/history",
2499
+ "GET /api/v1/page",
2500
+ "PUT /api/v1/page",
2501
+ "PATCH /api/v1/page",
2502
+ "DELETE /api/v1/page",
2503
+ "POST /api/v1/page/move",
2504
+ "POST /api/v1/audit",
2505
+ "POST /api/v1/pages/delete-batch",
2506
+ "POST /api/v1/links/extract",
2507
+ "POST /api/v1/links/suggest",
2508
+ "GET /api/v1/search",
2509
+ "POST /api/v1/logs",
2510
+ "POST /api/v1/import",
2511
+ "POST /api/v1/export",
2512
+ "GET /api/v1/export/download",
2513
+ "POST /api/v1/import-zip",
2514
+ "POST /api/v1/import-zip/upload",
2515
+ "GET /api/v1/jobs/{id}",
2516
+ "GET /api/v1/jobs/{id}/download",
2517
+ "POST /api/v1/purge",
2518
+ "GET /api/session-bundle",
2519
+ "GET /api/v1/session/bundle",
2520
+ "POST /api/v1/session/bundle",
2521
+ "GET /api/v1/tools",
2522
+ "POST /api/v1/tools/{name}",
2523
+ "GET /api/v1/metrics",
2524
+ "GET /metrics-ui",
2525
+ "POST /api/v1/tokens",
2526
+ "GET /api/v1/tokens",
2527
+ "DELETE /api/v1/tokens/{id}",
2528
+ "DELETE /api/v1/account",
2529
+ ],
2530
+ compatibility: {
2531
+ legacyRoutes: ["GET /tools", "POST /tool", "POST /chat", "GET /chat-ui", "GET /healthz"],
2532
+ mcp: "MCP transport remains available, but HTTP API is the primary integration surface.",
2533
+ },
2534
+ });
2535
+ return;
2536
+ }
2537
+ if (method === "GET" && pathname === "/cb") {
2538
+ html(req, res, 200, oauthCallbackHtml({
2539
+ code: parsedUrl.searchParams.get("code") ?? undefined,
2540
+ state: parsedUrl.searchParams.get("state") ?? undefined,
2541
+ error: parsedUrl.searchParams.get("error") ?? undefined,
2542
+ errorDescription: parsedUrl.searchParams.get("error_description") ?? undefined,
2543
+ }));
2544
+ return;
2545
+ }
2546
+ if (method === "GET" && pathname === "/connect") {
2547
+ const resolvedBase = publicBase();
2548
+ const base = resolvedBase !== `http://localhost:${port}`
2549
+ ? resolvedBase
2550
+ : `${req.headers["x-forwarded-proto"] ?? "https"}://${req.headers.host}`;
2551
+ const clientIds = new Set(oauth.getClientConfigs().map((c) => c.clientId));
2552
+ html(req, res, 200, connectPageHtml(base, clientIds));
2553
+ return;
2554
+ }
2555
+ if (method === "GET" && pathname === "/chat-ui") {
2556
+ html(req, res, 200, chatUiHtml());
2557
+ return;
2558
+ }
2559
+ if (method === "POST" && pathname === "/api/inbound-email") {
2560
+ let body = "";
2561
+ req.on("data", (chunk) => { body += chunk; });
2562
+ await new Promise((resolve) => req.on("end", resolve));
2563
+ let event;
2564
+ try {
2565
+ event = JSON.parse(body);
2566
+ }
2567
+ catch {
2568
+ json(req, res, 400, { error: "Invalid JSON" });
2569
+ return;
2570
+ }
2571
+ if (event.type !== "email.received") {
2572
+ json(req, res, 200, { ok: true, skipped: true });
2573
+ return;
2574
+ }
2575
+ const emailId = event.data?.email_id;
2576
+ if (!emailId) {
2577
+ json(req, res, 400, { error: "Missing email_id" });
2578
+ return;
2579
+ }
2580
+ const resendKey = process.env.RESEND_API_KEY;
2581
+ let text = null;
2582
+ let html_body = null;
2583
+ if (resendKey) {
2584
+ try {
2585
+ const emailRes = await fetch(`https://api.resend.com/emails/receiving/${emailId}`, {
2586
+ headers: { Authorization: `Bearer ${resendKey}` },
2587
+ signal: AbortSignal.timeout(8000),
2588
+ });
2589
+ if (emailRes.ok) {
2590
+ const full = await emailRes.json();
2591
+ text = full.text ?? null;
2592
+ html_body = full.html ?? null;
2593
+ }
2594
+ }
2595
+ catch { /* forward without body */ }
2596
+ }
2597
+ const webhookUrl = process.env.PAPERCLIP_WEBHOOK_URL;
2598
+ const webhookSecret = process.env.PAPERCLIP_WEBHOOK_SECRET;
2599
+ if (!webhookUrl || !webhookSecret) {
2600
+ json(req, res, 503, { error: "Webhook not configured" });
2601
+ return;
2602
+ }
2603
+ const enriched = {
2604
+ type: event.type,
2605
+ email_id: emailId,
2606
+ from: event.data?.from,
2607
+ to: event.data?.to,
2608
+ subject: event.data?.subject,
2609
+ text,
2610
+ html: html_body,
2611
+ };
2612
+ const fwdRes = await fetch(webhookUrl, {
2613
+ method: "POST",
2614
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${webhookSecret}` },
2615
+ body: JSON.stringify({ payload: enriched }),
2616
+ signal: AbortSignal.timeout(10000),
2617
+ });
2618
+ if (!fwdRes.ok) {
2619
+ const detail = await fwdRes.text().catch(() => "");
2620
+ json(req, res, 502, { error: "Upstream error", status: fwdRes.status, detail });
2621
+ return;
2622
+ }
2623
+ json(req, res, 200, { ok: true });
2624
+ return;
2625
+ }
2626
+ if (method === "GET" && pathname === "/healthz") {
2627
+ const result = await executeToolCall(requestDrive, "health_check", {}, requestUserId);
2628
+ json(req, res, 200, { ok: true, result });
2629
+ return;
2630
+ }
2631
+ if (method === "GET" && pathname === "/api/v1/health") {
2632
+ const result = await executeToolCall(requestDrive, "health_check", {}, requestUserId);
2633
+ json(req, res, 200, { ok: true, result });
2634
+ return;
2635
+ }
2636
+ if (method === "GET" && pathname === "/api/v1/launch/status") {
2637
+ const status = await getLaunchStatus(drive);
2638
+ json(req, res, 200, status, { "cache-control": "public, max-age=60" });
2639
+ return;
2640
+ }
2641
+ if (method === "GET" && pathname === "/api/v1/account/limits") {
2642
+ const { currentCount, limit } = await resolvePageLimitContext(requestUserId);
2643
+ const godMode = !requestUserId || isGodModeSubject(requestUserId);
2644
+ const tier = godMode ? "admin" : (currentCount > 0 ? "beta" : "free");
2645
+ json(req, res, 200, { ok: true, tier, pageCount: currentCount, pageLimit: limit });
2646
+ return;
2647
+ }
2648
+ if (method === "DELETE" && pathname === "/api/v1/account") {
2649
+ if (authorization.mode !== "supabase" && authorization.mode !== "oauth") {
2650
+ json(req, res, 403, { ok: false, error: "Account deletion requires a user session (Supabase or OAuth auth)" });
2651
+ return;
2652
+ }
2653
+ const userId = authorization.subject;
2654
+ const supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
2655
+ const serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
2656
+ if (!supabaseUrl || !serviceRoleKey) {
2657
+ json(req, res, 500, { ok: false, error: "Server not configured for account deletion" });
2658
+ return;
2659
+ }
2660
+ // Guard against concurrent or duplicate deletion requests
2661
+ const pendingCheckUrl = `${supabaseUrl}/rest/v1/pending_deletions?user_id=eq.${encodeURIComponent(userId)}&completed_at=is.null&limit=1`;
2662
+ const pendingCheckRes = await fetch(pendingCheckUrl, {
2663
+ headers: { apikey: serviceRoleKey, authorization: `Bearer ${serviceRoleKey}`, accept: "application/json" },
2664
+ });
2665
+ const existingRows = pendingCheckRes.ok
2666
+ ? (await pendingCheckRes.json().catch(() => []))
2667
+ : [];
2668
+ if (existingRows.length > 0) {
2669
+ json(req, res, 409, { ok: false, error: "Account deletion already in progress" });
2670
+ return;
2671
+ }
2672
+ const result = await deleteAccount(userId, { tokenStore, supabaseUrl, serviceRoleKey });
2673
+ if (!result.completed) {
2674
+ // Some cascade steps failed — do NOT report success, or the client
2675
+ // would sign the user out while their data is only partially deleted.
2676
+ json(req, res, 500, { ok: false, error: "Account deletion partially failed", details: result });
2677
+ return;
2678
+ }
2679
+ json(req, res, 200, { ok: true, ...result });
2680
+ return;
2681
+ }
2682
+ // ─────────────────────────────────────────────────────────────────────
2683
+ if (method === "GET" && pathname === "/api/v1/metrics") {
2684
+ const win = parsedUrl.searchParams.get("window") ?? "1h";
2685
+ if (!isValidWindow(win)) {
2686
+ json(req, res, 400, { ok: false, error: "Invalid window. Use: 1h, 6h, 24h, or 7d" });
2687
+ return;
2688
+ }
2689
+ const metrics = await queryMetrics(win);
2690
+ json(req, res, 200, metrics);
2691
+ return;
2692
+ }
2693
+ if (method === "GET" && pathname === "/metrics-ui") {
2694
+ html(req, res, 200, metricsUiHtml());
2695
+ return;
2696
+ }
2697
+ if (method === "GET" && pathname === "/tools") {
2698
+ json(req, res, 200, {
2699
+ ok: true,
2700
+ tools: getVisibleTools().map((t) => ({ name: t.name, description: t.description })),
2701
+ });
2702
+ return;
2703
+ }
2704
+ if (method === "GET" && pathname === "/api/v1/tools") {
2705
+ json(req, res, 200, {
2706
+ ok: true,
2707
+ tools: getVisibleTools().map((t) => ({
2708
+ name: t.name,
2709
+ description: t.description,
2710
+ inputSchema: t.inputSchema,
2711
+ })),
2712
+ });
2713
+ return;
2714
+ }
2715
+ if (method === "POST" && pathname.startsWith("/api/v1/tools/")) {
2716
+ const toolName = decodeURIComponent(pathname.slice("/api/v1/tools/".length));
2717
+ if (!toolName) {
2718
+ json(req, res, 400, { ok: false, error: "Tool name is required" });
2719
+ return;
2720
+ }
2721
+ const body = toolInvokeHttpSchema.parse(await readJsonBody(req));
2722
+ const result = await executeToolCall(requestDrive, toolName, body.arguments ?? {}, requestUserId, requestAccessCtx);
2723
+ json(req, res, 200, { ok: true, result });
2724
+ return;
2725
+ }
2726
+ if (method === "GET" && pathname === "/api/v1/pages") {
2727
+ const result = await executeToolCall(requestDrive, "list_pages", {
2728
+ tag: parsedUrl.searchParams.get("tag") ?? undefined,
2729
+ status: parsedUrl.searchParams.get("status") ?? undefined,
2730
+ folder: parsedUrl.searchParams.get("folder") ?? undefined,
2731
+ compact: parsedUrl.searchParams.get("compact") === "true" ? true : undefined,
2732
+ limit: coerceNumber(parsedUrl.searchParams.get("limit")) ?? undefined,
2733
+ offset: coerceNumber(parsedUrl.searchParams.get("offset")) ?? undefined,
2734
+ }, requestUserId, requestAccessCtx);
2735
+ json(req, res, 200, { ok: true, result });
2736
+ return;
2737
+ }
2738
+ const backlinksMatch = method === "GET"
2739
+ ? pathname.match(/^\/api\/v1\/pages\/(.+)\/backlinks$/)
2740
+ : null;
2741
+ if (backlinksMatch) {
2742
+ const name = decodeURIComponent(backlinksMatch[1] ?? "");
2743
+ const normalizedName = name.endsWith(".md") ? name : `${name}.md`;
2744
+ const overrideUserId = parsedUrl.searchParams.get("userId");
2745
+ let effectiveUserId = requestUserId;
2746
+ if (overrideUserId !== null && overrideUserId !== requestUserId) {
2747
+ if (authorization?.mode !== "api-key") {
2748
+ json(req, res, 403, { ok: false, error: "userId override requires api-key authentication" });
2749
+ return;
2750
+ }
2751
+ effectiveUserId = overrideUserId;
2752
+ }
2753
+ const result = await getPostgresSearchIndex().getBacklinks(normalizedName, effectiveUserId, isHiveModeEnabled() ? requestAccessCtx.userAccessLevel : undefined);
2754
+ json(req, res, 200, { ok: true, result });
2755
+ return;
2756
+ }
2757
+ const relatedMatch = method === "GET"
2758
+ ? pathname.match(/^\/api\/v1\/pages\/(.+)\/related$/)
2759
+ : null;
2760
+ if (relatedMatch) {
2761
+ const name = decodeURIComponent(relatedMatch[1] ?? "");
2762
+ const normalizedName = name.endsWith(".md") ? name : `${name}.md`;
2763
+ const overrideUserId = parsedUrl.searchParams.get("userId");
2764
+ let effectiveUserId = requestUserId;
2765
+ if (overrideUserId !== null && overrideUserId !== requestUserId) {
2766
+ if (authorization?.mode !== "api-key") {
2767
+ json(req, res, 403, { ok: false, error: "userId override requires api-key authentication" });
2768
+ return;
2769
+ }
2770
+ effectiveUserId = overrideUserId;
2771
+ }
2772
+ const result = await getPostgresSearchIndex().getRelatedPages(normalizedName, effectiveUserId, isHiveModeEnabled() ? requestAccessCtx.userAccessLevel : undefined);
2773
+ json(req, res, 200, { ok: true, result });
2774
+ return;
2775
+ }
2776
+ const historyMatch = method === "GET"
2777
+ ? pathname.match(/^\/api\/v1\/pages\/(.+)\/history$/)
2778
+ : null;
2779
+ if (historyMatch) {
2780
+ const name = decodeURIComponent(historyMatch[1] ?? "");
2781
+ const normalizedName = name.endsWith(".md") ? name : `${name}.md`;
2782
+ const result = await executeToolCall(requestDrive, "list_page_history", { name: normalizedName }, requestUserId);
2783
+ json(req, res, 200, { ok: true, result });
2784
+ return;
2785
+ }
2786
+ if (method === "POST" && pathname === "/api/v1/pages") {
2787
+ const body = createPageHttpSchema.parse(await readJsonBody(req));
2788
+ const result = await executeToolCall(requestDrive, "create_page", body, requestUserId);
2789
+ res._logExtras = { tool: "create_page" };
2790
+ json(req, res, 200, { ok: true, result });
2791
+ return;
2792
+ }
2793
+ if (method === "POST" && pathname === "/api/v1/pages/batch") {
2794
+ const body = createPagesHttpSchema.parse(await readJsonBody(req));
2795
+ if (batchInFlight >= CONCURRENCY_LIMIT_BATCH) {
2796
+ json(req, res, 503, { ok: false, error: "Server busy — try again shortly", retryAfter: 5 }, { "Retry-After": "5" });
2797
+ return;
2798
+ }
2799
+ batchInFlight++;
2800
+ try {
2801
+ const outcome = await raceWithTimeout(executeToolCall(requestDrive, "create_pages", body, requestUserId), TIMEOUT_DEFAULT_MS);
2802
+ if (outcome === TIMEOUT_SENTINEL) {
2803
+ if (!res.headersSent)
2804
+ json(req, res, 504, { ok: false, error: "Request timed out" });
2805
+ return;
2806
+ }
2807
+ res._logExtras = {
2808
+ tool: "create_pages",
2809
+ pageCount: body.pages.length,
2810
+ };
2811
+ json(req, res, 200, { ok: true, result: outcome });
2812
+ }
2813
+ finally {
2814
+ batchInFlight--;
2815
+ }
2816
+ return;
2817
+ }
2818
+ if (method === "GET" && pathname === "/api/v1/page") {
2819
+ const name = parsedUrl.searchParams.get("name");
2820
+ if (!name) {
2821
+ json(req, res, 400, { ok: false, error: "Query parameter 'name' is required" });
2822
+ return;
2823
+ }
2824
+ const result = await executeToolCall(requestDrive, "read_page", { name }, requestUserId, requestAccessCtx);
2825
+ // Conditional GET: clients (e.g. the Obsidian plugin) poll an open page
2826
+ // for freshness. When they send If-None-Match with the revision they
2827
+ // already hold and it still matches, answer 304 with no body so the poll
2828
+ // is a tiny request instead of a full content download.
2829
+ // (Future optimization: resolve the revision from the index to also skip
2830
+ // the read above; the bandwidth win is the dominant cost today.)
2831
+ const revision = result?.revision;
2832
+ if (revision) {
2833
+ const stripTag = (v) => v.trim().replace(/^W\//, "").replace(/^"|"$/g, "");
2834
+ const ifNoneMatch = req.headers["if-none-match"];
2835
+ const matched = typeof ifNoneMatch === "string" &&
2836
+ ifNoneMatch.split(",").some((tag) => stripTag(tag) === revision);
2837
+ if (matched) {
2838
+ res.writeHead(304, { etag: `"${revision}"`, "cache-control": "no-store" });
2839
+ res.end();
2840
+ return;
2841
+ }
2842
+ }
2843
+ // Phase 4: record implicit relevance signal if this read follows a recent
2844
+ // search. Only on a real 200 read — 304 polls must not spam this signal.
2845
+ const supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
2846
+ const serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
2847
+ maybeRecordRelevanceSignal(requestUserId, name, supabaseUrl, serviceRoleKey);
2848
+ json(req, res, 200, { ok: true, result }, revision ? { etag: `"${revision}"` } : undefined);
2849
+ return;
2850
+ }
2851
+ if (method === "PUT" && pathname === "/api/v1/page") {
2852
+ const body = updatePageHttpSchema.parse(await readJsonBody(req));
2853
+ const result = await executeToolCall(requestDrive, "update_page", body, requestUserId);
2854
+ res._logExtras = { tool: "update_page" };
2855
+ json(req, res, 200, { ok: true, result });
2856
+ return;
2857
+ }
2858
+ if (method === "PATCH" && pathname === "/api/v1/page") {
2859
+ const body = patchPageHttpSchema.parse(await readJsonBody(req));
2860
+ const result = await executeToolCall(requestDrive, "patch_page", body, requestUserId);
2861
+ res._logExtras = { tool: "patch_page" };
2862
+ json(req, res, 200, { ok: true, result });
2863
+ return;
2864
+ }
2865
+ if (method === "POST" && pathname === "/api/v1/page/move") {
2866
+ const body = movePageHttpSchema.parse(await readJsonBody(req));
2867
+ const normalizedName = body.name.endsWith(".md") ? body.name : `${body.name}.md`;
2868
+ const result = await executeToolCall(requestDrive, "move_page", { ...body, name: normalizedName }, requestUserId);
2869
+ res._logExtras = { tool: "move_page" };
2870
+ json(req, res, 200, { ok: true, result });
2871
+ return;
2872
+ }
2873
+ if (method === "POST" && pathname === "/api/v1/audit") {
2874
+ const body = auditIndexHttpSchema.parse(await readJsonBody(req));
2875
+ const result = await executeToolCall(requestDrive, "audit_index", body, requestUserId);
2876
+ res._logExtras = { tool: "audit_index" };
2877
+ json(req, res, 200, { ok: true, result });
2878
+ return;
2879
+ }
2880
+ if (method === "DELETE" && pathname === "/api/v1/page") {
2881
+ const name = parsedUrl.searchParams.get("name");
2882
+ const confirmParam = parsedUrl.searchParams.get("confirm");
2883
+ const reason = parsedUrl.searchParams.get("reason") ?? undefined;
2884
+ if (!name) {
2885
+ json(req, res, 400, { ok: false, error: "Query parameter 'name' is required" });
2886
+ return;
2887
+ }
2888
+ const confirm = confirmParam === "true";
2889
+ const result = await executeToolCall(requestDrive, "delete_page", { name, confirm, ...(reason ? { reason } : {}) }, requestUserId);
2890
+ res._logExtras = { tool: "delete_page" };
2891
+ json(req, res, 200, { ok: true, result });
2892
+ return;
2893
+ }
2894
+ if (method === "POST" && pathname === "/api/v1/pages/delete-batch") {
2895
+ const body = await readJsonBody(req);
2896
+ const result = await executeToolCall(requestDrive, "batch_delete_pages", body, requestUserId);
2897
+ res._logExtras = { tool: "batch_delete_pages" };
2898
+ json(req, res, 200, { ok: true, result });
2899
+ return;
2900
+ }
2901
+ // ── Webhook Rules REST endpoints ──────────────────────────────────────────
2902
+ if (method === "GET" && pathname === "/api/v1/webhooks") {
2903
+ const result = await executeToolCall(requestDrive, "list_webhooks", {}, requestUserId);
2904
+ json(req, res, 200, { ok: true, result });
2905
+ return;
2906
+ }
2907
+ if (method === "POST" && pathname === "/api/v1/webhooks") {
2908
+ const body = await readJsonBody(req);
2909
+ const result = await executeToolCall(requestDrive, "set_webhook", body, requestUserId);
2910
+ json(req, res, 200, { ok: true, result });
2911
+ return;
2912
+ }
2913
+ if (method === "DELETE" && pathname.startsWith("/api/v1/webhooks/")) {
2914
+ const id = pathname.slice("/api/v1/webhooks/".length);
2915
+ if (!id) {
2916
+ json(req, res, 400, { ok: false, error: "Webhook ID is required" });
2917
+ return;
2918
+ }
2919
+ const result = await executeToolCall(requestDrive, "remove_webhook", { id }, requestUserId);
2920
+ json(req, res, 200, { ok: true, result });
2921
+ return;
2922
+ }
2923
+ if (method === "POST" && pathname.endsWith("/patterns") && pathname.startsWith("/api/v1/webhooks/")) {
2924
+ const id = pathname.slice("/api/v1/webhooks/".length, -"/patterns".length);
2925
+ const body = await readJsonBody(req);
2926
+ if (!id || !body.pattern) {
2927
+ json(req, res, 400, { ok: false, error: "Webhook ID and pattern are required" });
2928
+ return;
2929
+ }
2930
+ const result = await executeToolCall(requestDrive, "add_webhook_pattern", { id, pattern: body.pattern }, requestUserId);
2931
+ json(req, res, 200, { ok: true, result });
2932
+ return;
2933
+ }
2934
+ // ── Personal Access Token (PAT) management ──────────────────────────────
2935
+ if (pathname === "/api/v1/tokens" || pathname.startsWith("/api/v1/tokens/")) {
2936
+ // PAT routes require a real user identity (supabase, oauth, or pat mode).
2937
+ if (!authorization ||
2938
+ (authorization.mode !== "supabase" &&
2939
+ authorization.mode !== "oauth" &&
2940
+ authorization.mode !== "pat")) {
2941
+ json(req, res, 403, {
2942
+ ok: false,
2943
+ error: "Forbidden",
2944
+ detail: "PAT management requires user authentication (OAuth or Supabase login).",
2945
+ });
2946
+ return;
2947
+ }
2948
+ const patSubject = authorization.subject;
2949
+ if (method === "GET" && pathname === "/api/v1/tokens") {
2950
+ void patStore.pruneExpired().catch(() => { });
2951
+ const pats = await patStore.list(patSubject);
2952
+ json(req, res, 200, { ok: true, tokens: pats });
2953
+ return;
2954
+ }
2955
+ if (method === "POST" && pathname === "/api/v1/tokens") {
2956
+ const body = createPATHttpSchema.parse(await readJsonBody(req));
2957
+ const scopes = body.scopes ?? ["wiki.read", "wiki.write"];
2958
+ // undefined → default (30 days); null → never expires; number → that many days
2959
+ const expiresInDays = body.expiresInDays !== undefined ? body.expiresInDays : PAT_EXPIRY_DAYS_DEFAULT;
2960
+ const expiresAt = expiresInDays === null ? null : Date.now() + expiresInDays * 24 * 60 * 60 * 1000;
2961
+ void patStore.pruneExpired().catch(() => { });
2962
+ const issued = await patStore.create(patSubject, body.label, scopes, expiresAt);
2963
+ json(req, res, 201, {
2964
+ ok: true,
2965
+ id: issued.id,
2966
+ token: issued.token,
2967
+ label: issued.label,
2968
+ scopes: issued.scopes,
2969
+ expiresAt: issued.expiresAt,
2970
+ createdAt: issued.createdAt,
2971
+ });
2972
+ return;
2973
+ }
2974
+ if (method === "DELETE" && pathname.startsWith("/api/v1/tokens/")) {
2975
+ const patId = pathname.slice("/api/v1/tokens/".length);
2976
+ if (!patId) {
2977
+ json(req, res, 400, { ok: false, error: "PAT id is required" });
2978
+ return;
2979
+ }
2980
+ await patStore.revoke(patId, patSubject);
2981
+ res.writeHead(204, { "cache-control": "no-store" });
2982
+ res.end();
2983
+ return;
2984
+ }
2985
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
2986
+ return;
2987
+ }
2988
+ if (method === "GET" && pathname === "/api/v1/search") {
2989
+ const query = parsedUrl.searchParams.get("query");
2990
+ if (!query) {
2991
+ json(req, res, 400, { ok: false, error: "Query parameter 'query' is required" });
2992
+ return;
2993
+ }
2994
+ const result = await executeToolCall(requestDrive, "search_pages", {
2995
+ query,
2996
+ tag: parsedUrl.searchParams.get("tag") ?? undefined,
2997
+ folder: parsedUrl.searchParams.get("folder") ?? undefined,
2998
+ maxCandidates: coerceNumber(parsedUrl.searchParams.get("maxCandidates")) ?? undefined,
2999
+ maxResults: coerceNumber(parsedUrl.searchParams.get("maxResults")) ?? undefined,
3000
+ maxConcurrency: coerceNumber(parsedUrl.searchParams.get("maxConcurrency")) ?? undefined,
3001
+ }, requestUserId, requestAccessCtx);
3002
+ // Attach search-specific fields for the request logging middleware.
3003
+ const sr = result;
3004
+ const durationMs = Date.now() - startTime;
3005
+ res._logExtras = {
3006
+ backend: sr.backend,
3007
+ queryMs: sr.timings?.queryMs,
3008
+ syncMs: sr.timings?.syncMs,
3009
+ zeroResults: sr.count === 0,
3010
+ resultCount: sr.count,
3011
+ queryText: query,
3012
+ };
3013
+ // Phase 4: slow-query alert + relevance signal correlation tracking.
3014
+ recordSearchLatency(durationMs);
3015
+ if (requestUserId && sr.results) {
3016
+ recordSearch(requestUserId, query, sr.results.map((r) => r.name));
3017
+ }
3018
+ json(req, res, 200, { ok: true, result });
3019
+ return;
3020
+ }
3021
+ if (method === "POST" && pathname === "/api/v1/logs") {
3022
+ const body = appendLogHttpSchema.parse(await readJsonBody(req));
3023
+ const result = await executeToolCall(requestDrive, "append_log", body, requestUserId);
3024
+ json(req, res, 200, { ok: true, result });
3025
+ return;
3026
+ }
3027
+ if (method === "POST" && pathname === "/api/v1/links/extract") {
3028
+ const body = extractLinksHttpSchema.parse(await readJsonBody(req));
3029
+ const tags = (body.tags ?? []).map((tag) => tag.trim()).filter((tag) => tag.length > 0);
3030
+ const folder = body.folder?.trim();
3031
+ if (tags.length === 0 && !folder) {
3032
+ json(req, res, 400, { ok: false, error: "At least one filter is required: tags or folder" });
3033
+ return;
3034
+ }
3035
+ let effectiveUserId = requestUserId;
3036
+ if (body.userId !== undefined && body.userId !== requestUserId) {
3037
+ if (authorization?.mode !== "api-key") {
3038
+ json(req, res, 403, { ok: false, error: "userId override requires api-key authentication" });
3039
+ return;
3040
+ }
3041
+ effectiveUserId = body.userId;
3042
+ }
3043
+ const result = await getPostgresSearchIndex().extractLinksForStalePages({
3044
+ tags,
3045
+ folder,
3046
+ userId: effectiveUserId,
3047
+ });
3048
+ json(req, res, 200, { ok: true, result });
3049
+ return;
3050
+ }
3051
+ if (method === "POST" && pathname === "/api/v1/links/suggest") {
3052
+ const body = suggestLinksHttpSchema.parse(await readJsonBody(req));
3053
+ const tags = (body.tags ?? []).map((tag) => tag.trim()).filter((tag) => tag.length > 0);
3054
+ const folder = body.folder?.trim();
3055
+ if (tags.length === 0 && !folder) {
3056
+ json(req, res, 400, { ok: false, error: "At least one filter is required: tags or folder" });
3057
+ return;
3058
+ }
3059
+ const result = await executeToolCall(requestDrive, "suggest_links", { tags, folder, maxPages: body.maxPages }, requestUserId, requestAccessCtx);
3060
+ json(req, res, 200, { ok: true, result });
3061
+ return;
3062
+ }
3063
+ if (method === "POST" && pathname === "/api/v1/import") {
3064
+ const body = importBrainSourceHttpSchema.parse(await readJsonBody(req));
3065
+ if (importInFlight >= CONCURRENCY_LIMIT_IMPORT) {
3066
+ json(req, res, 503, { ok: false, error: "Server busy — try again shortly", retryAfter: 5 }, { "Retry-After": "5" });
3067
+ return;
3068
+ }
3069
+ importInFlight++;
3070
+ try {
3071
+ const outcome = await raceWithTimeout(executeToolCall(requestDrive, "import_brain_source", body), TIMEOUT_IMPORT_MS);
3072
+ if (outcome === TIMEOUT_SENTINEL) {
3073
+ if (!res.headersSent)
3074
+ json(req, res, 504, { ok: false, error: "Request timed out" });
3075
+ return;
3076
+ }
3077
+ json(req, res, 200, { ok: true, result: outcome });
3078
+ }
3079
+ finally {
3080
+ importInFlight--;
3081
+ }
3082
+ return;
3083
+ }
3084
+ if (method === "POST" && pathname === "/api/v1/export") {
3085
+ const body = exportWikiHttpSchema.parse(await readJsonBody(req));
3086
+ const result = await executeToolCall(requestDrive, "export_wiki", body);
3087
+ json(req, res, 200, { ok: true, result });
3088
+ return;
3089
+ }
3090
+ if (method === "GET" && pathname === "/api/v1/export/download") {
3091
+ const folder = parsedUrl.searchParams.get("folder")?.trim() || undefined;
3092
+ const tagsParam = parsedUrl.searchParams.get("tags");
3093
+ const tags = tagsParam
3094
+ ? tagsParam.split(",").map((t) => t.trim()).filter(Boolean)
3095
+ : undefined;
3096
+ const includeSystemRaw = parsedUrl.searchParams.get("includeSystem");
3097
+ const includeSystem = includeSystemRaw ? includeSystemRaw.toLowerCase() === "true" : false;
3098
+ if (includeSystemRaw && includeSystemRaw.toLowerCase() !== "true" && includeSystemRaw.toLowerCase() !== "false") {
3099
+ json(req, res, 400, { ok: false, error: "Invalid includeSystem query param; expected true or false" });
3100
+ return;
3101
+ }
3102
+ const sinceParam = parsedUrl.searchParams.get("since")?.trim() || undefined;
3103
+ if (sinceParam && isNaN(new Date(sinceParam).getTime())) {
3104
+ json(req, res, 400, { ok: false, error: "Invalid 'since' param — expected ISO 8601 datetime, e.g. 2026-05-15T00:00:00Z" });
3105
+ return;
3106
+ }
3107
+ // Async job: avoids Railway 30s edge timeout on large wikis (500+ pages).
3108
+ // Client polls /api/v1/jobs/{id} then downloads from /api/v1/jobs/{id}/download.
3109
+ const job = asyncJobRegistry.create("export");
3110
+ json(req, res, 202, {
3111
+ ok: true,
3112
+ jobId: job.id,
3113
+ status: "queued",
3114
+ pollUrl: `/api/v1/jobs/${job.id}`,
3115
+ downloadUrl: `/api/v1/jobs/${job.id}/download`,
3116
+ });
3117
+ const capturedDrive = requestDrive;
3118
+ const capturedUserId = requestUserId;
3119
+ void (async () => {
3120
+ asyncJobRegistry.update(job.id, { status: "running", progress: { processed: 0, total: 0, phase: "exporting" } });
3121
+ try {
3122
+ const result = await buildExportZip(capturedDrive, {
3123
+ includeSystem,
3124
+ folder,
3125
+ tags,
3126
+ changedAfter: sinceParam,
3127
+ userId: capturedUserId,
3128
+ });
3129
+ const fileName = sinceParam
3130
+ ? `wiki-delta-${sinceParam.replace(/[:.]/g, "-")}.zip`
3131
+ : `wiki-export-${result.generatedAt.replace(/:/g, "-")}.zip`;
3132
+ asyncJobRegistry.setBinary(job.id, result.buffer, "application/zip", fileName);
3133
+ asyncJobRegistry.update(job.id, {
3134
+ status: "done",
3135
+ completedAt: new Date().toISOString(),
3136
+ progress: { processed: result.pageCount, total: result.pageCount, phase: "done" },
3137
+ result: {
3138
+ pageCount: result.pageCount,
3139
+ skippedCount: result.skippedCount,
3140
+ generatedAt: result.generatedAt,
3141
+ ...(result.since ? { since: result.since } : {}),
3142
+ downloadUrl: `/api/v1/jobs/${job.id}/download`,
3143
+ fileName,
3144
+ },
3145
+ });
3146
+ }
3147
+ catch (err) {
3148
+ asyncJobRegistry.update(job.id, {
3149
+ status: "failed",
3150
+ completedAt: new Date().toISOString(),
3151
+ error: err instanceof Error ? err.message : String(err),
3152
+ });
3153
+ }
3154
+ })();
3155
+ return;
3156
+ }
3157
+ if (method === "GET" && pathname === "/api/v1/session/bundle") {
3158
+ const tagsParam = parsedUrl.searchParams.get("tags");
3159
+ const folderParam = parsedUrl.searchParams.get("folder") ?? undefined;
3160
+ const tagFilter = tagsParam ? tagsParam.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
3161
+ if ((!tagFilter || tagFilter.length === 0) && !folderParam) {
3162
+ json(req, res, 400, { ok: false, error: "At least one of 'tags' or 'folder' query params is required" });
3163
+ return;
3164
+ }
3165
+ // Delegates to pullSessionBundle so this endpoint shares the Postgres-indexed
3166
+ // tag lookup with the MCP tool path. The previous inline scan read every .md
3167
+ // file just to inspect frontmatter — fine on small wikis but causes a 502
3168
+ // (Railway 90s edge timeout) once the wiki passes a few hundred pages.
3169
+ const result = await pullSessionBundle(requestDrive, {
3170
+ tags: tagFilter && tagFilter.length > 0 ? tagFilter : undefined,
3171
+ folder: folderParam,
3172
+ maxBytes: 150_000,
3173
+ maxPages: 50,
3174
+ includeSystem: false,
3175
+ }, requestUserId, requestAccessCtx);
3176
+ if (!result.ok) {
3177
+ json(req, res, 500, { ok: false, error: result.error ?? "Failed to build session bundle" });
3178
+ return;
3179
+ }
3180
+ const bundle = result.bundle ?? "";
3181
+ const pages = result.pages ?? [];
3182
+ const sessionToken = randomUUID();
3183
+ const bodyBuf = Buffer.from(bundle, "utf8");
3184
+ const { buf: outBuf, encoding: bundleEncoding } = compressIfNeeded(req, "text/plain", bodyBuf);
3185
+ const bundleHeaders = {
3186
+ "Content-Type": "text/plain; charset=utf-8",
3187
+ "Content-Length": String(outBuf.length),
3188
+ "X-Page-Count": String(pages.length),
3189
+ "X-Session-Token": sessionToken,
3190
+ };
3191
+ if (bundleEncoding) {
3192
+ bundleHeaders["Content-Encoding"] = bundleEncoding;
3193
+ bundleHeaders["Vary"] = "Accept-Encoding";
3194
+ }
3195
+ res.writeHead(200, bundleHeaders);
3196
+ res.end(outBuf);
3197
+ return;
3198
+ }
3199
+ if (method === "GET" && pathname === "/api/session-bundle") {
3200
+ // Collect tags[] array params and/or comma-separated tags param
3201
+ const tagsArrayParam = parsedUrl.searchParams.getAll("tags[]");
3202
+ const tagsParam = parsedUrl.searchParams.get("tags");
3203
+ const folderParam = parsedUrl.searchParams.get("folder") ?? undefined;
3204
+ const maxBytesParam = parsedUrl.searchParams.get("maxBytes");
3205
+ const maxBytes = maxBytesParam ? parseInt(maxBytesParam, 10) : 150_000;
3206
+ const tagFilter = [
3207
+ ...tagsArrayParam,
3208
+ ...(tagsParam ? tagsParam.split(",").map((t) => t.trim()).filter(Boolean) : []),
3209
+ ];
3210
+ if (tagFilter.length === 0 && !folderParam) {
3211
+ json(req, res, 400, { ok: false, error: "At least one of 'tags[]', 'tags', or 'folder' query params is required" });
3212
+ return;
3213
+ }
3214
+ const result = await pullSessionBundle(requestDrive, {
3215
+ tags: tagFilter.length > 0 ? tagFilter : undefined,
3216
+ folder: folderParam,
3217
+ maxBytes: isNaN(maxBytes) ? 150_000 : maxBytes,
3218
+ maxPages: 50,
3219
+ includeSystem: false,
3220
+ }, requestUserId, requestAccessCtx);
3221
+ if (!result.ok) {
3222
+ json(req, res, 500, { ok: false, error: result.error ?? "Failed to build session bundle" });
3223
+ return;
3224
+ }
3225
+ const bundle = result.bundle ?? "";
3226
+ const pages = result.pages ?? [];
3227
+ const sessionToken = randomUUID();
3228
+ const bodyBuf = Buffer.from(bundle, "utf8");
3229
+ const { buf: outBuf, encoding: bundleEncoding } = compressIfNeeded(req, "text/plain", bodyBuf);
3230
+ const bundleHeaders = {
3231
+ "Content-Type": "text/plain; charset=utf-8",
3232
+ "Content-Length": String(outBuf.length),
3233
+ "X-Page-Count": String(pages.length),
3234
+ "X-Session-Token": sessionToken,
3235
+ };
3236
+ if (bundleEncoding) {
3237
+ bundleHeaders["Content-Encoding"] = bundleEncoding;
3238
+ bundleHeaders["Vary"] = "Accept-Encoding";
3239
+ }
3240
+ res.writeHead(200, bundleHeaders);
3241
+ res.end(outBuf);
3242
+ return;
3243
+ }
3244
+ if (method === "POST" && pathname === "/api/v1/session/bundle") {
3245
+ const rawBody = await readBody(req);
3246
+ let bundlePages;
3247
+ try {
3248
+ bundlePages = parseBundle(rawBody);
3249
+ }
3250
+ catch {
3251
+ json(req, res, 400, { ok: false, error: "Invalid bundle format" });
3252
+ return;
3253
+ }
3254
+ // Delegates to pushSessionBundle so this endpoint writes through Postgres the
3255
+ // same way the MCP tool path does. The previous inline loop wrote only to
3256
+ // Storage, which left pushed content invisible to read_page/patch_page once
3257
+ // those went DB-primary (BRA-1813) — see open-loop-session-bundle-postgres-writethrough.
3258
+ // includeSystem:true preserves this endpoint's prior no-skip behavior.
3259
+ const result = await pushSessionBundle(requestDrive, {
3260
+ pages: bundlePages,
3261
+ conflictStrategy: "overwrite",
3262
+ includeSystem: true,
3263
+ }, requestUserId);
3264
+ const errors = result.errors ?? [];
3265
+ const response = {
3266
+ ok: errors.length === 0,
3267
+ updated: Number(result.updated ?? 0),
3268
+ created: Number(result.created ?? 0),
3269
+ };
3270
+ if (errors.length > 0) {
3271
+ response.failed = errors.map((e) => ({ name: e.path, error: e.reason }));
3272
+ }
3273
+ json(req, res, 200, response);
3274
+ return;
3275
+ }
3276
+ if (method === "GET" && /^\/api\/v1\/jobs\/[^/]+$/.test(pathname)) {
3277
+ const jobId = pathname.split("/").pop() ?? "";
3278
+ const job = asyncJobRegistry.get(jobId);
3279
+ if (!job) {
3280
+ json(req, res, 404, { ok: false, error: "Job not found or expired" });
3281
+ return;
3282
+ }
3283
+ json(req, res, 200, { ok: true, job });
3284
+ return;
3285
+ }
3286
+ if (method === "GET" && /^\/api\/v1\/jobs\/[^/]+\/download$/.test(pathname)) {
3287
+ const parts = pathname.split("/");
3288
+ const jobId = parts[parts.length - 2] ?? "";
3289
+ const job = asyncJobRegistry.get(jobId);
3290
+ if (!job) {
3291
+ json(req, res, 404, { ok: false, error: "Job not found or expired" });
3292
+ return;
3293
+ }
3294
+ if (job.status !== "done") {
3295
+ json(req, res, 409, { ok: false, error: `Job is ${job.status} — poll /api/v1/jobs/${jobId} until status is 'done'` });
3296
+ return;
3297
+ }
3298
+ const binary = asyncJobRegistry.getBinary(jobId);
3299
+ if (!binary) {
3300
+ json(req, res, 404, { ok: false, error: "Binary result not available (job may have expired)" });
3301
+ return;
3302
+ }
3303
+ res.writeHead(200, {
3304
+ "Content-Type": binary.contentType,
3305
+ "Content-Disposition": `attachment; filename="${binary.filename}"`,
3306
+ "Content-Length": String(binary.buffer.length),
3307
+ "Cache-Control": "no-store",
3308
+ });
3309
+ res.end(binary.buffer);
3310
+ return;
3311
+ }
3312
+ if (method === "POST" && pathname === "/api/v1/import-zip") {
3313
+ const body = importWikiZipHttpSchema.parse(await readJsonBody(req));
3314
+ const result = await executeToolCall(requestDrive, "import_wiki", body, requestUserId);
3315
+ json(req, res, 200, { ok: true, result });
3316
+ return;
3317
+ }
3318
+ if (method === "POST" && pathname === "/api/v1/import-zip/upload") {
3319
+ const contentType = (req.headers["content-type"] ?? "").toString().toLowerCase();
3320
+ if (!contentType.includes("application/zip")) {
3321
+ json(req, res, 415, { ok: false, error: "Content-Type must be application/zip" });
3322
+ return;
3323
+ }
3324
+ const executeRaw = parsedUrl.searchParams.get("execute");
3325
+ const execute = executeRaw ? executeRaw.toLowerCase() === "true" : true;
3326
+ if (executeRaw && executeRaw.toLowerCase() !== "true" && executeRaw.toLowerCase() !== "false") {
3327
+ json(req, res, 400, { ok: false, error: "Invalid execute query param; expected true or false" });
3328
+ return;
3329
+ }
3330
+ const includeSystemRaw = parsedUrl.searchParams.get("includeSystem");
3331
+ const includeSystem = includeSystemRaw ? includeSystemRaw.toLowerCase() === "true" : false;
3332
+ if (includeSystemRaw && includeSystemRaw.toLowerCase() !== "true" && includeSystemRaw.toLowerCase() !== "false") {
3333
+ json(req, res, 400, { ok: false, error: "Invalid includeSystem query param; expected true or false" });
3334
+ return;
3335
+ }
3336
+ const conflictStrategyRaw = parsedUrl.searchParams.get("conflictStrategy");
3337
+ const conflictStrategy = conflictStrategyRaw ?? "merge";
3338
+ if (!["skip", "overwrite", "merge"].includes(conflictStrategy)) {
3339
+ json(req, res, 400, { ok: false, error: "Invalid conflictStrategy query param; expected skip, overwrite, or merge" });
3340
+ return;
3341
+ }
3342
+ const modeRaw = parsedUrl.searchParams.get("mode");
3343
+ const mode = modeRaw ?? "additive";
3344
+ if (!["additive", "replace"].includes(mode)) {
3345
+ json(req, res, 400, { ok: false, error: "Invalid mode query param; expected additive or replace" });
3346
+ return;
3347
+ }
3348
+ const logTimingRaw = parsedUrl.searchParams.get("logTiming");
3349
+ const logTiming = logTimingRaw ? logTimingRaw.toLowerCase() === "true" : false;
3350
+ if (logTimingRaw && logTimingRaw.toLowerCase() !== "true" && logTimingRaw.toLowerCase() !== "false") {
3351
+ json(req, res, 400, { ok: false, error: "Invalid logTiming query param; expected true or false" });
3352
+ return;
3353
+ }
3354
+ const effectiveConflictStrategy = mode === "replace" ? "overwrite" : conflictStrategy;
3355
+ const zipBuffer = await readBodyBuffer(req, MAX_IMPORT_ZIP_UPLOAD_BYTES);
3356
+ const startedAt = new Date().toISOString();
3357
+ const startMs = Date.now();
3358
+ if (!execute) {
3359
+ // Preview mode: async job to avoid Railway 30s gateway timeout on large wikis.
3360
+ const job = asyncJobRegistry.create("import-preview");
3361
+ json(req, res, 202, {
3362
+ ok: true,
3363
+ jobId: job.id,
3364
+ status: "queued",
3365
+ pollUrl: `/api/v1/jobs/${job.id}`,
3366
+ });
3367
+ const capturedDrive = requestDrive;
3368
+ void (async () => {
3369
+ asyncJobRegistry.update(job.id, { status: "running", progress: { processed: 0, total: 0, phase: "scanning" } });
3370
+ try {
3371
+ const preview = await scanImportDiffFromBuffer(capturedDrive, zipBuffer, {
3372
+ conflictStrategy: effectiveConflictStrategy,
3373
+ includeSystem,
3374
+ mode: mode,
3375
+ onProgress: (processed, total) => {
3376
+ asyncJobRegistry.update(job.id, { progress: { processed, total, phase: "scanning" } });
3377
+ },
3378
+ });
3379
+ asyncJobRegistry.update(job.id, {
3380
+ status: "done",
3381
+ completedAt: new Date().toISOString(),
3382
+ result: {
3383
+ success: true,
3384
+ mode: "preview",
3385
+ importMode: mode,
3386
+ zipPath: "(uploaded)",
3387
+ startedAt,
3388
+ durationMs: Date.now() - startMs,
3389
+ totalEntries: preview.totalEntries,
3390
+ newCount: preview.newCount,
3391
+ modifiedCount: preview.modifiedCount,
3392
+ unchangedCount: preview.unchangedCount,
3393
+ skippedCount: preview.skippedCount,
3394
+ deletedCount: preview.deletedCount,
3395
+ diff: preview.diff,
3396
+ },
3397
+ });
3398
+ }
3399
+ catch (err) {
3400
+ asyncJobRegistry.update(job.id, {
3401
+ status: "failed",
3402
+ completedAt: new Date().toISOString(),
3403
+ error: err instanceof Error ? err.message : String(err),
3404
+ });
3405
+ }
3406
+ })();
3407
+ return;
3408
+ }
3409
+ // Execute mode: async job — returns immediately, job runs in background.
3410
+ // Avoids Railway 30s edge timeout on large imports (500+ pages).
3411
+ const job = asyncJobRegistry.create("import");
3412
+ json(req, res, 202, {
3413
+ ok: true,
3414
+ jobId: job.id,
3415
+ status: "queued",
3416
+ pollUrl: `/api/v1/jobs/${job.id}`,
3417
+ });
3418
+ const capturedDrive = requestDrive;
3419
+ const capturedUserId = requestUserId;
3420
+ void (async () => {
3421
+ asyncJobRegistry.update(job.id, { status: "running", progress: { processed: 0, total: 0, phase: "scanning" } });
3422
+ try {
3423
+ const preview = await scanImportDiffFromBuffer(capturedDrive, zipBuffer, {
3424
+ conflictStrategy: effectiveConflictStrategy,
3425
+ includeSystem,
3426
+ mode: mode,
3427
+ onProgress: (processed, total) => {
3428
+ asyncJobRegistry.update(job.id, { progress: { processed, total, phase: "scanning" } });
3429
+ },
3430
+ });
3431
+ const deletedNames = mode === "replace"
3432
+ ? preview.diff.filter((item) => item.status === "deleted").map((item) => item.name)
3433
+ : [];
3434
+ asyncJobRegistry.update(job.id, { progress: { processed: 0, total: preview.totalEntries, phase: "writing" } });
3435
+ const importResult = await runImportFromBuffer(capturedDrive, zipBuffer, {
3436
+ conflictStrategy: effectiveConflictStrategy,
3437
+ includeSystem,
3438
+ deletedNames,
3439
+ userId: capturedUserId,
3440
+ onProgress: (processed, total, phase) => {
3441
+ asyncJobRegistry.update(job.id, { progress: { processed, total, phase } });
3442
+ },
3443
+ });
3444
+ const durationMs = Date.now() - startMs;
3445
+ const completedAt = new Date().toISOString();
3446
+ const failures = importResult.errors.map((e) => ({ name: e.path, error: e.reason }));
3447
+ const appliedCount = importResult.created + importResult.updated;
3448
+ const deletedCount = importResult.deleted;
3449
+ const appliedOrChanged = appliedCount > 0 || deletedCount > 0;
3450
+ const discoveredFolders = computeDiscoveredFolders(preview.diff);
3451
+ if (logTiming) {
3452
+ await appendLogEntry(capturedDrive, {
3453
+ type: "ingest",
3454
+ title: "Import wiki zip upload",
3455
+ detail: `mode=${mode}, deleted=${deletedCount}, applied=${appliedCount}, failed=${failures.length}, durationMs=${durationMs}`,
3456
+ }, capturedUserId).catch(() => { });
3457
+ }
3458
+ asyncJobRegistry.update(job.id, {
3459
+ status: "done",
3460
+ completedAt,
3461
+ progress: { processed: appliedCount + deletedCount, total: appliedCount + deletedCount, phase: "done" },
3462
+ result: {
3463
+ success: failures.length === 0,
3464
+ mode: "execute",
3465
+ importMode: mode,
3466
+ zipPath: "(uploaded)",
3467
+ startedAt,
3468
+ completedAt,
3469
+ durationMs,
3470
+ totalEntries: preview.totalEntries,
3471
+ appliedCount,
3472
+ deletedCount,
3473
+ failedCount: failures.length,
3474
+ failures,
3475
+ warnings: importResult.warnings.map((w) => ({ name: w.path, warning: w.reason })),
3476
+ diff: preview.diff,
3477
+ ...(appliedOrChanged ? {
3478
+ instructionRefreshRequired: true,
3479
+ discoveredFolders,
3480
+ nextAction: buildInstructionRefreshHint(discoveredFolders),
3481
+ } : {}),
3482
+ },
3483
+ });
3484
+ }
3485
+ catch (err) {
3486
+ asyncJobRegistry.update(job.id, {
3487
+ status: "failed",
3488
+ completedAt: new Date().toISOString(),
3489
+ error: err instanceof Error ? err.message : String(err),
3490
+ });
3491
+ }
3492
+ })();
3493
+ return;
3494
+ }
3495
+ if (method === "POST" && pathname === "/api/v1/purge") {
3496
+ const body = purgeWikiHttpSchema.parse(await readJsonBody(req));
3497
+ if (!body.confirm) {
3498
+ // Dry-run: synchronous (just lists files, fast)
3499
+ if (purgeInFlight >= CONCURRENCY_LIMIT_PURGE) {
3500
+ json(req, res, 503, { ok: false, error: "Server busy — try again shortly", retryAfter: 5 }, { "Retry-After": "5" });
3501
+ return;
3502
+ }
3503
+ purgeInFlight++;
3504
+ try {
3505
+ const result = await executeToolCall(requestDrive, "purge_wiki", body, requestUserId);
3506
+ json(req, res, 200, { ok: true, result });
3507
+ }
3508
+ finally {
3509
+ purgeInFlight--;
3510
+ }
3511
+ return;
3512
+ }
3513
+ // confirm=true: async job — avoids Railway 30s edge timeout on large wikis.
3514
+ const job = asyncJobRegistry.create("purge");
3515
+ json(req, res, 202, {
3516
+ ok: true,
3517
+ jobId: job.id,
3518
+ status: "queued",
3519
+ pollUrl: `/api/v1/jobs/${job.id}`,
3520
+ });
3521
+ const capturedDrive = requestDrive;
3522
+ const capturedUserId = requestUserId;
3523
+ void (async () => {
3524
+ asyncJobRegistry.update(job.id, { status: "running", progress: { processed: 0, total: 0, phase: "purging" } });
3525
+ try {
3526
+ const purgeInput = {
3527
+ confirm: true,
3528
+ exportBeforeDelete: body.exportBeforeDelete ?? false,
3529
+ exportOutputPath: body.exportOutputPath,
3530
+ includeSystem: body.includeSystem ?? false,
3531
+ };
3532
+ const result = await purgeWiki(capturedDrive, purgeInput, capturedUserId, {
3533
+ onProgress: (processed, total) => {
3534
+ asyncJobRegistry.update(job.id, { progress: { processed, total, phase: "purging" } });
3535
+ },
3536
+ });
3537
+ asyncJobRegistry.update(job.id, {
3538
+ status: "done",
3539
+ completedAt: new Date().toISOString(),
3540
+ result,
3541
+ });
3542
+ }
3543
+ catch (err) {
3544
+ asyncJobRegistry.update(job.id, {
3545
+ status: "failed",
3546
+ completedAt: new Date().toISOString(),
3547
+ error: err instanceof Error ? err.message : String(err),
3548
+ });
3549
+ }
3550
+ })();
3551
+ return;
3552
+ }
3553
+ if (method === "POST" && pathname === "/tool") {
3554
+ const body = await readJsonBody(req);
3555
+ const parsed = toolCallSchema.parse(body);
3556
+ const result = await executeToolCall(requestDrive, parsed.name, parsed.arguments ?? {}, requestUserId, requestAccessCtx);
3557
+ json(req, res, 200, { ok: true, result });
3558
+ return;
3559
+ }
3560
+ if (method === "POST" && pathname === "/chat") {
3561
+ const body = await readJsonBody(req);
3562
+ const parsed = chatRequestSchema.parse(body);
3563
+ if (chatInFlight >= CONCURRENCY_LIMIT_CHAT) {
3564
+ json(req, res, 503, { ok: false, error: "Server busy — try again shortly", retryAfter: 5 }, { "Retry-After": "5" });
3565
+ return;
3566
+ }
3567
+ chatInFlight++;
3568
+ try {
3569
+ const outcome = await raceWithTimeout(runClaudeWithTools(requestDrive, parsed, requestUserId, requestAccessCtx), TIMEOUT_CHAT_MS);
3570
+ if (outcome === TIMEOUT_SENTINEL) {
3571
+ if (!res.headersSent)
3572
+ json(req, res, 504, { ok: false, error: "Request timed out" });
3573
+ return;
3574
+ }
3575
+ json(req, res, 200, { ok: true, ...outcome });
3576
+ }
3577
+ finally {
3578
+ chatInFlight--;
3579
+ }
3580
+ return;
3581
+ }
3582
+ if (method === "POST" && pathname === "/admin/reindex-fts") {
3583
+ if (!isApiKeyAuthorized(req)) {
3584
+ json(req, res, 403, { ok: false, error: "Admin access required" });
3585
+ return;
3586
+ }
3587
+ try {
3588
+ const result = await getPostgresSearchIndex().reindexFts();
3589
+ json(req, res, 200, { ok: true, ...result });
3590
+ }
3591
+ catch (err) {
3592
+ json(req, res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
3593
+ }
3594
+ return;
3595
+ }
3596
+ if (method === "GET" && pathname === "/admin/users") {
3597
+ const godModeSubjects = (process.env.BRAINS_GOD_MODE_SUBJECTS ?? "")
3598
+ .split(",").map(s => s.trim()).filter(Boolean);
3599
+ const isGodMode = !!(authorization?.subject && godModeSubjects.includes(authorization.subject));
3600
+ if (!isApiKeyAuthorized(req) && !isGodMode) {
3601
+ json(req, res, 403, { ok: false, error: "Admin access required" });
3602
+ return;
3603
+ }
3604
+ const sinceParam = parsedUrl.searchParams.get("since");
3605
+ const sinceMs = sinceParam ? new Date(sinceParam).getTime() : undefined;
3606
+ if (sinceParam && (sinceMs === undefined || isNaN(sinceMs))) {
3607
+ json(req, res, 400, { ok: false, error: "Invalid 'since' date" });
3608
+ return;
3609
+ }
3610
+ let userRecords = [];
3611
+ const supabaseUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
3612
+ const serviceRoleKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
3613
+ if (supabaseUrl && serviceRoleKey) {
3614
+ // Paginate through all Supabase auth users (max 1000 per page).
3615
+ // Each fetch is guarded by a 15-second AbortController so a hung
3616
+ // Supabase auth-admin connection never stalls the entire request.
3617
+ const allSupaUsers = [];
3618
+ let page = 1;
3619
+ // eslint-disable-next-line no-constant-condition
3620
+ while (true) {
3621
+ const url = `${supabaseUrl}/auth/v1/admin/users?page=${page}&per_page=1000`;
3622
+ const ac = new AbortController();
3623
+ const acTimer = setTimeout(() => ac.abort(), 15_000);
3624
+ const resp = await fetch(url, {
3625
+ headers: { apikey: serviceRoleKey, authorization: `Bearer ${serviceRoleKey}` },
3626
+ signal: ac.signal,
3627
+ }).catch(() => null).finally(() => clearTimeout(acTimer));
3628
+ if (!resp || !resp.ok)
3629
+ break;
3630
+ const body = await resp.json().catch(() => null);
3631
+ const batch = body?.users ?? [];
3632
+ if (batch.length === 0)
3633
+ break;
3634
+ for (const u of batch) {
3635
+ // subject = email for display; filesystemSubject = Supabase UUID (matches users/<id>/ dirs)
3636
+ allSupaUsers.push({ subject: u.email ?? u.id, filesystemSubject: u.id, registeredAt: u.created_at });
3637
+ }
3638
+ if (batch.length < 1000)
3639
+ break;
3640
+ page++;
3641
+ }
3642
+ userRecords = sinceMs
3643
+ ? allSupaUsers.filter(u => new Date(u.registeredAt).getTime() >= sinceMs)
3644
+ : allSupaUsers;
3645
+ }
3646
+ if (userRecords.length === 0) {
3647
+ const usersBasePath = path.join(drive.getStorageLocation(), "users");
3648
+ const userDirs = await fs.readdir(usersBasePath, { withFileTypes: true }).catch(() => []);
3649
+ const allDirRecords = await Promise.all(userDirs.filter(d => d.isDirectory()).map(async (d) => {
3650
+ const stat = await fs.stat(path.join(usersBasePath, d.name)).catch(() => null);
3651
+ return { subject: d.name, filesystemSubject: d.name, registeredAt: (stat?.birthtime ?? stat?.mtime ?? new Date()).toISOString() };
3652
+ }));
3653
+ userRecords = sinceMs
3654
+ ? allDirRecords.filter(u => new Date(u.registeredAt).getTime() >= sinceMs)
3655
+ : allDirRecords;
3656
+ if (allDirRecords.length === 0) {
3657
+ const clients = await clientStore.listAll(sinceMs);
3658
+ userRecords = clients.map(c => ({ subject: c.clientId, filesystemSubject: c.clientId, registeredAt: new Date(c.createdAt).toISOString() }));
3659
+ }
3660
+ }
3661
+ // Count pages and storage per user.
3662
+ // Page count: prefer Postgres (fast, accurate) when configured; fall back to storage listing.
3663
+ // Storage bytes: always from storage listing (no bytes in Postgres), with 15s timeout.
3664
+ // Use filesystemSubject (UUID) not display subject (email) for scope lookups.
3665
+ const pgIdx = getPostgresSearchIndex();
3666
+ const pgConfigured = pgIdx.isConfigured();
3667
+ const listFilesTimeout = () => new Promise((_, reject) => setTimeout(() => reject(new Error("listFiles timeout")), 15_000));
3668
+ const users = await Promise.all(userRecords.map(async (u) => {
3669
+ const subjectKey = sanitizeSubjectForPath(u.filesystemSubject);
3670
+ const userScoped = createScopedStorageClient(drive, `users/${subjectKey}`);
3671
+ let pageCount = 0;
3672
+ let storageBytes = 0;
3673
+ // Use Postgres for page count — avoids storage walk timeout on large wikis.
3674
+ if (pgConfigured) {
3675
+ pageCount = await pgIdx.countActivePages(u.filesystemSubject).catch(() => 0);
3676
+ }
3677
+ try {
3678
+ const files = await Promise.race([userScoped.listFiles(), listFilesTimeout()]);
3679
+ storageBytes = files.reduce((acc, f) => acc + f.size, 0);
3680
+ if (!pgConfigured) {
3681
+ pageCount = files.filter(f => f.name.endsWith(".md")).length;
3682
+ }
3683
+ }
3684
+ catch { /* user has no files yet, or timed out */ }
3685
+ return { subject: u.subject, registeredAt: u.registeredAt, pageCount, storageBytes };
3686
+ }));
3687
+ const totalStorageBytes = users.reduce((acc, u) => acc + u.storageBytes, 0);
3688
+ json(req, res, 200, { ok: true, users, total: users.length, totalStorageBytes });
3689
+ return;
3690
+ }
3691
+ // ── Admin: seed-users (staging-only, god-mode gated) ────────────────────
3692
+ if (pathname === "/admin/seed-users") {
3693
+ if (!isSeedEnabled()) {
3694
+ json(req, res, 404, { ok: false, error: "Not found" });
3695
+ return;
3696
+ }
3697
+ if (!authorization || !isGodModeSubject(authorization.subject)) {
3698
+ json(req, res, 403, { ok: false, error: "Forbidden — god-mode required" });
3699
+ return;
3700
+ }
3701
+ if (method === "POST") {
3702
+ const body = await readJsonBody(req);
3703
+ const count = typeof body.count === "number" ? body.count : 1;
3704
+ const tier = typeof body.tier === "string" ? body.tier : "free_beta";
3705
+ const prefix = typeof body.prefix === "string" ? body.prefix.trim() : "seed";
3706
+ if (!prefix || !/^[a-z0-9-]+$/i.test(prefix)) {
3707
+ json(req, res, 400, { ok: false, error: "prefix must be alphanumeric/hyphens" });
3708
+ return;
3709
+ }
3710
+ const validTiers = ["free_beta", "launch_user", "solo", "pro", "team"];
3711
+ if (!validTiers.includes(tier)) {
3712
+ json(req, res, 400, { ok: false, error: `tier must be one of: ${validTiers.join(", ")}` });
3713
+ return;
3714
+ }
3715
+ const result = await seedUsers(drive, {
3716
+ count: Math.min(Math.max(1, Math.floor(count)), 100),
3717
+ tier: tier,
3718
+ prefix,
3719
+ });
3720
+ json(req, res, 200, { ok: true, ...result });
3721
+ return;
3722
+ }
3723
+ if (method === "DELETE") {
3724
+ const prefix = parsedUrl.searchParams.get("prefix")?.trim() ?? "";
3725
+ if (!prefix || !/^[a-z0-9-]+$/i.test(prefix)) {
3726
+ json(req, res, 400, { ok: false, error: "Query param 'prefix' must be alphanumeric/hyphens" });
3727
+ return;
3728
+ }
3729
+ const result = await purgeSeedUsers(prefix);
3730
+ json(req, res, 200, { ok: true, ...result });
3731
+ return;
3732
+ }
3733
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
3734
+ return;
3735
+ }
3736
+ // ── Digest: opt-in preferences ─────────────────────────────────────────
3737
+ if (pathname === "/api/v1/digest/preferences") {
3738
+ if (!authorization) {
3739
+ unauthorized(res, `${publicBase()}/.well-known/oauth-protected-resource`);
3740
+ return;
3741
+ }
3742
+ if (!hasRequiredScopes(authorization.scopes, ["wiki.read"])) {
3743
+ forbidden(res, ["wiki.read"]);
3744
+ return;
3745
+ }
3746
+ const digestSupaUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
3747
+ const digestSrKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
3748
+ if (!digestSupaUrl || !digestSrKey) {
3749
+ json(req, res, 503, { ok: false, error: "Digest feature requires Supabase configuration" });
3750
+ return;
3751
+ }
3752
+ if (method === "GET") {
3753
+ const pref = await getDigestPreference(digestSupaUrl, digestSrKey, authorization.subject);
3754
+ json(req, res, 200, {
3755
+ ok: true,
3756
+ enabled: pref?.enabled ?? false,
3757
+ lastSentAt: pref?.last_sent_at ?? null,
3758
+ });
3759
+ return;
3760
+ }
3761
+ if (method === "PATCH") {
3762
+ const body = await readJsonBody(req);
3763
+ const enabledRaw = body["enabled"];
3764
+ if (typeof enabledRaw !== "boolean") {
3765
+ json(req, res, 400, { ok: false, error: "Body must include 'enabled' boolean" });
3766
+ return;
3767
+ }
3768
+ if (enabledRaw) {
3769
+ // Opt-in: upsert with the user's current email from auth context
3770
+ const userEmail = typeof body["email"] === "string"
3771
+ ? body["email"].trim()
3772
+ : "";
3773
+ if (!userEmail) {
3774
+ json(req, res, 400, { ok: false, error: "Provide 'email' in body when enabling digest" });
3775
+ return;
3776
+ }
3777
+ await upsertDigestPreference(digestSupaUrl, digestSrKey, {
3778
+ user_id: authorization.subject,
3779
+ email: userEmail,
3780
+ enabled: true,
3781
+ });
3782
+ }
3783
+ else {
3784
+ // Opt-out: just flip the flag (leave email/created_at intact)
3785
+ await updateDigestEnabled(digestSupaUrl, digestSrKey, authorization.subject, false);
3786
+ }
3787
+ json(req, res, 200, { ok: true, enabled: enabledRaw });
3788
+ return;
3789
+ }
3790
+ json(req, res, 405, { ok: false, error: "Method not allowed" });
3791
+ return;
3792
+ }
3793
+ // ── Digest: manual trigger (for testing) ────────────────────────────────
3794
+ if (method === "POST" && pathname === "/api/v1/digest/trigger") {
3795
+ if (!authorization) {
3796
+ unauthorized(res, `${publicBase()}/.well-known/oauth-protected-resource`);
3797
+ return;
3798
+ }
3799
+ if (!hasRequiredScopes(authorization.scopes, ["wiki.write"])) {
3800
+ forbidden(res, ["wiki.write"]);
3801
+ return;
3802
+ }
3803
+ const digestSupaUrl = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
3804
+ const digestSrKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
3805
+ const resendKey = (process.env.RESEND_API_KEY ?? "").trim();
3806
+ const fromEmail = (process.env.DIGEST_FROM_EMAIL ?? "digest@usebrains.app").trim();
3807
+ const fromName = (process.env.DIGEST_FROM_NAME ?? "brains Weekly Digest").trim();
3808
+ const body = await readJsonBody(req);
3809
+ const overrideEmail = typeof body["email"] === "string" ? body["email"].trim() : "";
3810
+ if (!overrideEmail) {
3811
+ json(req, res, 400, { ok: false, error: "Body must include 'email' for the digest recipient" });
3812
+ return;
3813
+ }
3814
+ const result = await runDigestForUser(authorization.subject, overrideEmail, requestDrive, { supabaseUrl: digestSupaUrl, serviceRoleKey: digestSrKey, resendApiKey: resendKey, fromEmail, fromName });
3815
+ json(req, res, 200, { ok: true, result });
3816
+ return;
3817
+ }
3818
+ json(req, res, 404, { ok: false, error: "Not found" });
3819
+ }
3820
+ catch (err) {
3821
+ if (err instanceof z.ZodError) {
3822
+ json(req, res, 400, { ok: false, error: `Invalid arguments: ${err.message}` });
3823
+ return;
3824
+ }
3825
+ if (err instanceof Error && err.message.startsWith("Unknown tool:")) {
3826
+ json(req, res, 400, { ok: false, error: err.message });
3827
+ return;
3828
+ }
3829
+ if (err instanceof ToolNotAvailableError) {
3830
+ json(req, res, 404, { ok: false, error: err.message });
3831
+ return;
3832
+ }
3833
+ if (err instanceof StorageLimitError) {
3834
+ json(req, res, 429, err.toJSON());
3835
+ return;
3836
+ }
3837
+ if (err instanceof BodyTooLargeError) {
3838
+ json(req, res, 413, { ok: false, error: err.message });
3839
+ return;
3840
+ }
3841
+ if (err instanceof PageLimitError) {
3842
+ json(req, res, 429, err.toJSON());
3843
+ return;
3844
+ }
3845
+ if (err instanceof Error && err.message.includes("OAuth")) {
3846
+ json(req, res, 400, {
3847
+ ok: false,
3848
+ error: err.message,
3849
+ });
3850
+ return;
3851
+ }
3852
+ const message = err instanceof Error ? err.message : String(err);
3853
+ console.error(`[brains] Unhandled request error: ${message}`, err instanceof Error ? err.stack : "");
3854
+ json(req, res, 500, { ok: false, error: message });
3855
+ }
3856
+ });
3857
+ server.setTimeout(TIMEOUT_DEFAULT_MS);
3858
+ // Keep connections alive longer than Railway's 60s proxy timeout so warm
3859
+ // MCP clients reuse the same TCP/TLS connection across sequential tool calls.
3860
+ server.keepAliveTimeout = 65_000;
3861
+ server.headersTimeout = 70_000;
3862
+ await new Promise((resolve, reject) => {
3863
+ server.once("error", reject);
3864
+ server.listen(port, "0.0.0.0", () => resolve());
3865
+ });
3866
+ console.error(`[brains] HTTP server listening on 0.0.0.0:${port}`);
3867
+ // ── Phase 4: start background crons (non-blocking) ────────────────────────
3868
+ const supabaseUrlBg = (process.env.SUPABASE_URL ?? "").trim().replace(/\/$/, "");
3869
+ const serviceRoleKeyBg = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? "").trim();
3870
+ if (supabaseUrlBg && serviceRoleKeyBg) {
3871
+ // Warm up hot query cache from top queries in the last 7 days.
3872
+ void (async () => {
3873
+ try {
3874
+ const topQueries = await fetchTopQueries(supabaseUrlBg, serviceRoleKeyBg);
3875
+ if (topQueries.length > 0) {
3876
+ const { searchPages: sp } = await import("./tools/searchPages.js");
3877
+ await Promise.allSettled(topQueries.map(async (q) => {
3878
+ try {
3879
+ const result = await sp(drive, { query: q, maxCandidates: 120, maxResults: 80, maxConcurrency: 8 }, "");
3880
+ setHotCached("", q, result);
3881
+ }
3882
+ catch {
3883
+ // non-fatal
3884
+ }
3885
+ }));
3886
+ console.error(`[metrics] hot cache warmed up with ${topQueries.length} queries`);
3887
+ }
3888
+ }
3889
+ catch {
3890
+ // non-fatal
3891
+ }
3892
+ })();
3893
+ // Start zero-result triage weekly cron.
3894
+ startZeroResultTriageCron(supabaseUrlBg, serviceRoleKeyBg, () => drive);
3895
+ // Start weekly brain digest cron (Sunday 08:00 UTC).
3896
+ const resendApiKey = (process.env.RESEND_API_KEY ?? "").trim();
3897
+ const digestFromEmail = (process.env.DIGEST_FROM_EMAIL ?? "digest@usebrains.app").trim();
3898
+ const digestFromName = (process.env.DIGEST_FROM_NAME ?? "brains Weekly Digest").trim();
3899
+ startDigestCron(drive, {
3900
+ supabaseUrl: supabaseUrlBg,
3901
+ serviceRoleKey: serviceRoleKeyBg,
3902
+ resendApiKey,
3903
+ fromEmail: digestFromEmail,
3904
+ fromName: digestFromName,
3905
+ });
3906
+ }
3907
+ // Prune expired PATs daily.
3908
+ const patPruneCron = setInterval(() => { void patStore.pruneExpired().catch(() => { }); }, 24 * 60 * 60 * 1000);
3909
+ if (typeof patPruneCron.unref === "function")
3910
+ patPruneCron.unref();
3911
+ return server;
3912
+ }
3913
+ //# sourceMappingURL=httpServer.js.map