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,825 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { DriveAuthError, DriveConflictError, DriveNotFoundError, DrivePermissionError, } from "../drive/errors.js";
5
+ const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
6
+ const PROTECTED_ROOT_FOLDERS = new Set([
7
+ "instructions",
8
+ "canonical",
9
+ "data",
10
+ "decisions",
11
+ "history",
12
+ "projects",
13
+ ]);
14
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
15
+ function contentRevision(content) {
16
+ return crypto.createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
17
+ }
18
+ function isEnoent(err) {
19
+ return err?.code === "ENOENT";
20
+ }
21
+ function normalizeWikiPath(name) {
22
+ const normalized = name.replace(/\\/g, "/");
23
+ const segments = normalized.split("/");
24
+ if (path.isAbsolute(name) ||
25
+ normalized.startsWith("/") ||
26
+ segments.some((s) => s.length === 0 || s === "." || s === "..")) {
27
+ throw new DrivePermissionError(`Unsafe filename rejected: '${name}'`);
28
+ }
29
+ for (const segment of segments.slice(0, -1)) {
30
+ if (!/^[a-z0-9-]+$/.test(segment)) {
31
+ throw new DrivePermissionError(`Unsafe path segment rejected: '${segment}' in '${name}'`);
32
+ }
33
+ }
34
+ const last = segments[segments.length - 1] ?? "";
35
+ if (!/^[a-z0-9-]+\.md$/.test(last)) {
36
+ throw new DrivePermissionError(`Unsafe filename rejected: '${last}' in '${name}'`);
37
+ }
38
+ return normalized;
39
+ }
40
+ function combinePosixPrefix(base, suffix) {
41
+ const a = base.trim().replace(/^\/+|\/+$/g, "");
42
+ const b = suffix.trim().replace(/^\/+|\/+$/g, "");
43
+ if (!a)
44
+ return b;
45
+ if (!b)
46
+ return a;
47
+ return `${a}/${b}`;
48
+ }
49
+ function encodePathSegments(value) {
50
+ return value
51
+ .split("/")
52
+ .filter((segment) => segment.length > 0)
53
+ .map((segment) => encodeURIComponent(segment))
54
+ .join("/");
55
+ }
56
+ // ─── LocalClient ─────────────────────────────────────────────────────────────
57
+ export class LocalClient {
58
+ wikiDir;
59
+ backup;
60
+ constructor(wikiDir, backup = null) {
61
+ this.wikiDir = path.resolve(wikiDir);
62
+ this.backup = backup;
63
+ }
64
+ getStorageLocation() {
65
+ return this.wikiDir;
66
+ }
67
+ async verifyAccess() {
68
+ try {
69
+ await fs.mkdir(this.wikiDir, { recursive: true });
70
+ await fs.access(this.wikiDir, fs.constants.R_OK | fs.constants.W_OK);
71
+ }
72
+ catch (err) {
73
+ throw new DriveAuthError(`Cannot access wiki directory '${this.wikiDir}': ${err}`);
74
+ }
75
+ }
76
+ async fileExists(name) {
77
+ this.assertSafePath(name);
78
+ return fs.stat(path.join(this.wikiDir, name)).then(() => true).catch(() => false);
79
+ }
80
+ async listFiles() {
81
+ await fs.mkdir(this.wikiDir, { recursive: true });
82
+ const results = [];
83
+ const walk = async (relativeDir) => {
84
+ const dirPath = path.join(this.wikiDir, relativeDir);
85
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
86
+ for (const e of entries) {
87
+ const relPath = relativeDir ? path.posix.join(relativeDir, e.name) : e.name;
88
+ if (e.isDirectory()) {
89
+ await walk(relPath);
90
+ continue;
91
+ }
92
+ if (!e.isFile() || !relPath.endsWith(".md"))
93
+ continue;
94
+ try {
95
+ const stat = await fs.stat(path.join(this.wikiDir, relPath));
96
+ results.push({
97
+ fileId: relPath,
98
+ name: relPath,
99
+ modifiedTime: stat.mtime.toISOString(),
100
+ size: stat.size,
101
+ });
102
+ }
103
+ catch {
104
+ // skip unreadable entries
105
+ }
106
+ }
107
+ };
108
+ await walk("");
109
+ return results.sort((a, b) => a.name.localeCompare(b.name));
110
+ }
111
+ async readFile(name) {
112
+ this.assertSafePath(name);
113
+ const filePath = path.join(this.wikiDir, name);
114
+ try {
115
+ const [content, stat] = await Promise.all([
116
+ fs.readFile(filePath, "utf8"),
117
+ fs.stat(filePath),
118
+ ]);
119
+ return {
120
+ fileId: name,
121
+ name,
122
+ content,
123
+ revision: contentRevision(content),
124
+ modifiedTime: stat.mtime.toISOString(),
125
+ };
126
+ }
127
+ catch (err) {
128
+ if (isEnoent(err))
129
+ throw new DriveNotFoundError(name);
130
+ throw err;
131
+ }
132
+ }
133
+ async createFile(name, content) {
134
+ this.assertSafePath(name);
135
+ if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
136
+ throw new Error(`File '${name}' exceeds max size of ${MAX_FILE_SIZE_BYTES} bytes`);
137
+ }
138
+ const filePath = path.join(this.wikiDir, name);
139
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
140
+ // Guard against accidental overwrite using exclusive write flag
141
+ try {
142
+ await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
143
+ }
144
+ catch (err) {
145
+ if (err.code === "EEXIST") {
146
+ throw new DrivePermissionError(`File '${name}' already exists. Use update_page instead.`);
147
+ }
148
+ throw err;
149
+ }
150
+ const stat = await fs.stat(filePath);
151
+ const result = {
152
+ fileId: name,
153
+ revision: contentRevision(content),
154
+ modifiedTime: stat.mtime.toISOString(),
155
+ };
156
+ this.scheduleBackup(name, content);
157
+ return result;
158
+ }
159
+ async updateFile(fileId, content, seenRevision) {
160
+ // For local storage, fileId === filename
161
+ const name = fileId;
162
+ this.assertSafePath(name);
163
+ if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
164
+ throw new Error(`Content exceeds max size of ${MAX_FILE_SIZE_BYTES} bytes`);
165
+ }
166
+ const filePath = path.join(this.wikiDir, name);
167
+ const existing = await fs.readFile(filePath, "utf8").catch((err) => {
168
+ if (isEnoent(err))
169
+ throw new DriveNotFoundError(name);
170
+ throw err;
171
+ });
172
+ // Optimistic concurrency: compare content hashes
173
+ const currentRevision = contentRevision(existing);
174
+ if (seenRevision && currentRevision !== seenRevision) {
175
+ throw new DriveConflictError(name, seenRevision, currentRevision);
176
+ }
177
+ await fs.writeFile(filePath, content, "utf8");
178
+ const stat = await fs.stat(filePath);
179
+ const result = {
180
+ fileId: name,
181
+ revision: contentRevision(content),
182
+ modifiedTime: stat.mtime.toISOString(),
183
+ };
184
+ this.scheduleBackup(name, content);
185
+ return result;
186
+ }
187
+ async renameFile(fileId, newName) {
188
+ const oldName = fileId;
189
+ this.assertSafePath(oldName);
190
+ this.assertSafePath(newName);
191
+ const oldPath = path.join(this.wikiDir, oldName);
192
+ const newPath = path.join(this.wikiDir, newName);
193
+ await fs.mkdir(path.dirname(newPath), { recursive: true });
194
+ try {
195
+ await fs.rename(oldPath, newPath);
196
+ }
197
+ catch (err) {
198
+ if (isEnoent(err))
199
+ throw new DriveNotFoundError(oldName);
200
+ throw err;
201
+ }
202
+ const content = await fs.readFile(newPath, "utf8");
203
+ const stat = await fs.stat(newPath);
204
+ const result = {
205
+ fileId: newName,
206
+ revision: contentRevision(content),
207
+ modifiedTime: stat.mtime.toISOString(),
208
+ };
209
+ this.scheduleBackup(newName, content);
210
+ return result;
211
+ }
212
+ async deleteFile(name) {
213
+ this.assertSafePath(name);
214
+ const filePath = path.join(this.wikiDir, name);
215
+ try {
216
+ await fs.unlink(filePath);
217
+ }
218
+ catch (err) {
219
+ if (isEnoent(err))
220
+ throw new DriveNotFoundError(name);
221
+ throw err;
222
+ }
223
+ await this.pruneEmptyParentDirs(name);
224
+ }
225
+ async bootstrapWikiFiles() {
226
+ await fs.mkdir(this.wikiDir, { recursive: true });
227
+ const today = new Date().toISOString().slice(0, 10);
228
+ const bootstrapIfMissing = async (filename, initialContent) => {
229
+ const filePath = path.join(this.wikiDir, filename);
230
+ try {
231
+ await fs.writeFile(filePath, initialContent, { encoding: "utf8", flag: "wx" });
232
+ }
233
+ catch (err) {
234
+ if (err.code !== "EEXIST")
235
+ throw err;
236
+ }
237
+ };
238
+ await bootstrapIfMissing("index.md", `---\ntitle: Index\nupdated: ${today}\n---\n\n# Wiki Index\n\n| ID | Page | Summary | Updated |\n|---|---|---|---|\n`);
239
+ await bootstrapIfMissing("log.md", `---\ntitle: Log\nupdated: ${today}\n---\n\n# Wiki Log\n\nAppend-only record of ingests, queries, and lint passes.\n\n`);
240
+ }
241
+ // ── Private helpers ────────────────────────────────────────────────────────
242
+ assertSafePath(name) {
243
+ normalizeWikiPath(name);
244
+ }
245
+ scheduleBackup(name, content) {
246
+ if (!this.backup)
247
+ return;
248
+ this.backup.backup(name, content).catch((err) => {
249
+ console.warn(`[backup] Failed to back up '${name}' to Drive:`, err);
250
+ });
251
+ }
252
+ async pruneEmptyParentDirs(name) {
253
+ let relativeDir = path.posix.dirname(name.replace(/\\/g, "/"));
254
+ while (relativeDir && relativeDir !== ".") {
255
+ if (!relativeDir.includes("/") && PROTECTED_ROOT_FOLDERS.has(relativeDir)) {
256
+ break;
257
+ }
258
+ const dirPath = path.join(this.wikiDir, relativeDir);
259
+ let entries;
260
+ try {
261
+ entries = await fs.readdir(dirPath);
262
+ }
263
+ catch (err) {
264
+ if (isEnoent(err))
265
+ break;
266
+ throw err;
267
+ }
268
+ if (entries.length > 0)
269
+ break;
270
+ await fs.rmdir(dirPath).catch((err) => {
271
+ if (!isEnoent(err))
272
+ throw err;
273
+ });
274
+ const parent = path.posix.dirname(relativeDir);
275
+ if (parent === relativeDir)
276
+ break;
277
+ relativeDir = parent;
278
+ }
279
+ }
280
+ }
281
+ export class SupabaseStorageClient {
282
+ supabaseUrl;
283
+ serviceRoleKey;
284
+ bucket;
285
+ prefix;
286
+ cacheLocation;
287
+ backup;
288
+ constructor(options) {
289
+ this.supabaseUrl = options.supabaseUrl.trim().replace(/\/$/, "");
290
+ this.serviceRoleKey = options.serviceRoleKey.trim();
291
+ this.bucket = options.bucket.trim();
292
+ this.prefix = options.prefix?.trim().replace(/^\/+|\/+$/g, "") ?? "";
293
+ this.cacheLocation = path.resolve(options.cacheLocation ?? path.join(".wiki-cache", "supabase", this.cacheKeySegment()));
294
+ this.backup = options.backup ?? null;
295
+ }
296
+ withPrefix(additionalPrefix) {
297
+ const scopedPrefix = combinePosixPrefix(this.prefix, additionalPrefix);
298
+ return new SupabaseStorageClient({
299
+ supabaseUrl: this.supabaseUrl,
300
+ serviceRoleKey: this.serviceRoleKey,
301
+ bucket: this.bucket,
302
+ prefix: scopedPrefix,
303
+ cacheLocation: path.join(process.env.WIKI_CACHE_DIR ?? path.join(".wiki-cache", "supabase"), this.sanitizeForCache(scopedPrefix || "root")),
304
+ backup: this.backup,
305
+ });
306
+ }
307
+ getStorageLocation() {
308
+ return this.cacheLocation;
309
+ }
310
+ async verifyAccess() {
311
+ if (!this.supabaseUrl || !this.serviceRoleKey || !this.bucket) {
312
+ throw new DriveAuthError("Supabase storage not configured. Set SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and SUPABASE_STORAGE_BUCKET.");
313
+ }
314
+ await fs.mkdir(this.cacheLocation, { recursive: true });
315
+ // Cheap bucket-reachability check: list at most 1 object
316
+ const objectPrefix = this.prefix ? `${this.prefix}/` : "";
317
+ const url = `${this.supabaseUrl}/storage/v1/object/list/${encodeURIComponent(this.bucket)}`;
318
+ const response = await fetch(url, {
319
+ method: "POST",
320
+ headers: this.authHeaders({ "content-type": "application/json" }),
321
+ body: JSON.stringify({ prefix: objectPrefix, limit: 1, offset: 0 }),
322
+ }).catch(() => null);
323
+ if (!response || !response.ok) {
324
+ const body = response ? await response.text().catch(() => "") : "network error";
325
+ throw new DriveAuthError(`Cannot access Supabase storage bucket '${this.bucket}': ${response?.status ?? ""} ${body}`);
326
+ }
327
+ }
328
+ async listFiles() {
329
+ const rows = [];
330
+ await this.walkList("", rows);
331
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
332
+ }
333
+ /**
334
+ * HEAD the object rather than downloading it.
335
+ *
336
+ * Deliberately conservative: only a 404 is treated as "absent". Any other
337
+ * non-OK status falls back to a full read, because the sole caller uses this
338
+ * to detect an externally wiped wiki, and a false negative would trigger an
339
+ * unnecessary re-bootstrap and index backfill.
340
+ */
341
+ async fileExists(name) {
342
+ const safeName = normalizeWikiPath(name);
343
+ const encoded = encodePathSegments(this.objectPath(safeName));
344
+ const url = `${this.supabaseUrl}/storage/v1/object/${this.bucket}/${encoded}`;
345
+ const response = await fetch(url, { method: "HEAD", headers: this.authHeaders() }).catch(() => null);
346
+ if (response?.ok)
347
+ return true;
348
+ if (response?.status === 404)
349
+ return false;
350
+ return this.readFile(safeName).then(() => true).catch((err) => {
351
+ if (err instanceof DriveNotFoundError)
352
+ return false;
353
+ throw err;
354
+ });
355
+ }
356
+ async readFile(name) {
357
+ const safeName = normalizeWikiPath(name);
358
+ const objectPath = this.objectPath(safeName);
359
+ const encoded = encodePathSegments(objectPath);
360
+ const cacheBust = `${Date.now().toString(36)}-${contentRevision(objectPath).slice(0, 8)}`;
361
+ const url = `${this.supabaseUrl}/storage/v1/object/${this.bucket}/${encoded}?_cb=${cacheBust}`;
362
+ const response = await fetch(url, {
363
+ method: "GET",
364
+ headers: this.authHeaders({
365
+ "cache-control": "no-cache, no-store, max-age=0",
366
+ pragma: "no-cache",
367
+ }),
368
+ }).catch(() => null);
369
+ if (!response || response.status === 404) {
370
+ throw new DriveNotFoundError(safeName);
371
+ }
372
+ if (!response.ok) {
373
+ // Supabase Storage returns HTTP 400 (not 404) for missing objects;
374
+ // detect this by checking the JSON body's statusCode field.
375
+ if (response.status === 400) {
376
+ const body = await response.json().catch(() => null);
377
+ if (body?.statusCode === "404") {
378
+ throw new DriveNotFoundError(safeName);
379
+ }
380
+ }
381
+ throw new Error(`Supabase read failed for '${safeName}' (${response.status})`);
382
+ }
383
+ const content = await response.text();
384
+ const modifiedTime = this.parseModifiedTime(response.headers.get("last-modified"));
385
+ return {
386
+ fileId: safeName,
387
+ name: safeName,
388
+ content,
389
+ revision: contentRevision(content),
390
+ modifiedTime,
391
+ };
392
+ }
393
+ async createFile(name, content) {
394
+ const safeName = normalizeWikiPath(name);
395
+ if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
396
+ throw new Error(`File '${safeName}' exceeds max size of ${MAX_FILE_SIZE_BYTES} bytes`);
397
+ }
398
+ const existing = await this.readFile(safeName).then(() => true).catch((err) => {
399
+ if (err instanceof DriveNotFoundError)
400
+ return false;
401
+ throw err;
402
+ });
403
+ if (existing) {
404
+ throw new DrivePermissionError(`File '${safeName}' already exists. Use update_page instead.`);
405
+ }
406
+ await this.uploadObject(safeName, content, false);
407
+ const result = {
408
+ fileId: safeName,
409
+ revision: contentRevision(content),
410
+ modifiedTime: new Date().toISOString(),
411
+ };
412
+ this.scheduleBackup(safeName, content);
413
+ return result;
414
+ }
415
+ async updateFile(fileId, content, seenRevision) {
416
+ const safeName = normalizeWikiPath(fileId);
417
+ if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
418
+ throw new Error(`Content exceeds max size of ${MAX_FILE_SIZE_BYTES} bytes`);
419
+ }
420
+ // Skip the re-read that the original code used for conflict detection.
421
+ // Supabase Storage has no server-side CAS so conflict detection is always
422
+ // application-level; the extra GET here (~1-2 s on hosted Supabase) provided
423
+ // no stronger guarantee than the caller's own preceding readFile call.
424
+ // When seenRevision is absent there is nothing to compare against anyway.
425
+ await this.uploadObject(safeName, content, true);
426
+ const result = {
427
+ fileId: safeName,
428
+ revision: contentRevision(content),
429
+ modifiedTime: new Date().toISOString(),
430
+ };
431
+ this.scheduleBackup(safeName, content);
432
+ return result;
433
+ }
434
+ async renameFile(fileId, newName) {
435
+ const oldName = normalizeWikiPath(fileId);
436
+ const nextName = normalizeWikiPath(newName);
437
+ const existing = await this.readFile(oldName);
438
+ await this.uploadObject(nextName, existing.content, true);
439
+ await this.deleteObject(oldName);
440
+ const result = {
441
+ fileId: nextName,
442
+ revision: contentRevision(existing.content),
443
+ modifiedTime: new Date().toISOString(),
444
+ };
445
+ this.scheduleBackup(nextName, existing.content);
446
+ return result;
447
+ }
448
+ async deleteFile(name) {
449
+ const safeName = normalizeWikiPath(name);
450
+ await this.deleteObject(safeName);
451
+ await this.pruneEmptyParentDirs(safeName);
452
+ }
453
+ async bootstrapWikiFiles() {
454
+ const today = new Date().toISOString().slice(0, 10);
455
+ const bootstrapIfMissing = async (filename, initialContent) => {
456
+ const exists = await this.readFile(filename).then(() => true).catch((err) => {
457
+ if (err instanceof DriveNotFoundError)
458
+ return false;
459
+ throw err;
460
+ });
461
+ if (!exists) {
462
+ await this.createFile(filename, initialContent);
463
+ }
464
+ };
465
+ // Seed structural template files from the bundled seed/ directory.
466
+ // seed/ contains only taxonomy scaffolding — no user-specific content.
467
+ // Only creates missing files; never overwrites existing content so user
468
+ // edits are preserved. Present in the Docker image via `COPY . .`.
469
+ const seedSourceDir = path.resolve("seed");
470
+ let seededFromDisk = false;
471
+ let seedDirExists = false;
472
+ try {
473
+ const stat = await fs.stat(seedSourceDir);
474
+ seedDirExists = stat.isDirectory();
475
+ }
476
+ catch {
477
+ // seed/ not present in this build — fall through to minimal fallback
478
+ }
479
+ if (seedDirExists) {
480
+ let seededCount = 0;
481
+ const walkDir = async (dir, relBase) => {
482
+ const entries = await fs.readdir(dir, { withFileTypes: true });
483
+ for (const entry of entries) {
484
+ const fullPath = path.join(dir, entry.name);
485
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
486
+ if (entry.isDirectory()) {
487
+ await walkDir(fullPath, relPath);
488
+ }
489
+ else if (entry.name.endsWith(".md")) {
490
+ try {
491
+ const content = await fs.readFile(fullPath, "utf8");
492
+ const existed = await this.readFile(relPath).then(() => true).catch((err) => {
493
+ if (err instanceof DriveNotFoundError)
494
+ return false;
495
+ throw err;
496
+ });
497
+ if (!existed) {
498
+ await this.createFile(relPath, content);
499
+ seededCount += 1;
500
+ }
501
+ }
502
+ catch (err) {
503
+ console.warn(`[bootstrap] Failed to seed '${relPath}':`, err instanceof Error ? err.message : err);
504
+ }
505
+ }
506
+ }
507
+ };
508
+ try {
509
+ await walkDir(seedSourceDir, "");
510
+ seededFromDisk = true;
511
+ console.log(`[bootstrap] Seeded ${seededCount} file(s) from seed/`);
512
+ }
513
+ catch (err) {
514
+ console.warn("[bootstrap] walkDir error:", err instanceof Error ? err.message : err);
515
+ }
516
+ }
517
+ else {
518
+ console.log(`[bootstrap] seed/ dir not found, using minimal fallback`);
519
+ }
520
+ if (!seededFromDisk) {
521
+ console.log("[bootstrap] Seeding from disk unavailable; ensuring minimal root files");
522
+ }
523
+ // Always ensure root index/log exist, even when seed/ is present.
524
+ // seed/ currently contains taxonomy scaffolding and may not include root files.
525
+ await bootstrapIfMissing("index.md", `---\ntitle: Index\nupdated: ${today}\n---\n\n# Wiki Index\n\n| ID | Page | Summary | Updated |\n|---|---|---|---|\n`);
526
+ await bootstrapIfMissing("log.md", `---\ntitle: Log\nupdated: ${today}\n---\n\n# Wiki Log\n\nAppend-only record of ingests, queries, and lint passes.\n\n`);
527
+ }
528
+ authHeaders(extra) {
529
+ return {
530
+ apikey: this.serviceRoleKey,
531
+ authorization: `Bearer ${this.serviceRoleKey}`,
532
+ ...extra,
533
+ };
534
+ }
535
+ objectPath(name) {
536
+ return this.prefix ? `${this.prefix}/${name}` : name;
537
+ }
538
+ async walkList(relativePrefix, results, depth = 0) {
539
+ if (depth > 15)
540
+ return;
541
+ const items = await this.listObjects(relativePrefix);
542
+ const subdirPromises = [];
543
+ for (const item of items) {
544
+ if (this.isDirectoryItem(item)) {
545
+ const nextPrefix = relativePrefix ? `${relativePrefix}/${item.name}` : item.name;
546
+ subdirPromises.push(this.walkList(nextPrefix, results, depth + 1));
547
+ }
548
+ else {
549
+ const relName = relativePrefix ? `${relativePrefix}/${item.name}` : item.name;
550
+ if (relName.endsWith(".md")) {
551
+ results.push({
552
+ fileId: relName,
553
+ name: relName,
554
+ modifiedTime: item.updated_at ?? item.created_at ?? new Date().toISOString(),
555
+ size: Number(item.metadata?.size ?? 0),
556
+ });
557
+ }
558
+ }
559
+ }
560
+ await Promise.all(subdirPromises);
561
+ }
562
+ async listObjects(relativePrefix) {
563
+ const objectPrefix = combinePosixPrefix(this.prefix, relativePrefix);
564
+ const url = `${this.supabaseUrl}/storage/v1/object/list/${encodeURIComponent(this.bucket)}`;
565
+ const response = await fetch(url, {
566
+ method: "POST",
567
+ headers: this.authHeaders({ "content-type": "application/json" }),
568
+ body: JSON.stringify({
569
+ prefix: objectPrefix,
570
+ limit: 1000,
571
+ offset: 0,
572
+ }),
573
+ }).catch(() => null);
574
+ if (!response) {
575
+ throw new Error("Supabase list request failed");
576
+ }
577
+ if (!response.ok) {
578
+ const body = await response.text().catch(() => "");
579
+ throw new Error(`Supabase list failed (${response.status}): ${body}`);
580
+ }
581
+ const payload = (await response.json().catch(() => []));
582
+ return Array.isArray(payload) ? payload : [];
583
+ }
584
+ async uploadObject(name, content, upsert) {
585
+ const objectPath = this.objectPath(name);
586
+ const url = `${this.supabaseUrl}/storage/v1/object/${this.bucket}/${encodePathSegments(objectPath)}`;
587
+ const response = await fetch(url, {
588
+ method: "POST",
589
+ headers: this.authHeaders({
590
+ "content-type": "text/markdown; charset=utf-8",
591
+ "cache-control": "no-store, max-age=0",
592
+ "x-upsert": upsert ? "true" : "false",
593
+ }),
594
+ body: content,
595
+ }).catch(() => null);
596
+ if (!response) {
597
+ throw new Error(`Supabase upload failed for '${name}'`);
598
+ }
599
+ if (!response.ok) {
600
+ if (!upsert && response.status === 409) {
601
+ throw new DrivePermissionError(`File '${name}' already exists. Use update_page instead.`);
602
+ }
603
+ const body = await response.text().catch(() => "");
604
+ throw new Error(`Supabase upload failed for '${name}' (${response.status}): ${body}`);
605
+ }
606
+ }
607
+ async deleteObject(name) {
608
+ const objectPath = this.objectPath(name);
609
+ const url = `${this.supabaseUrl}/storage/v1/object/${this.bucket}/${encodePathSegments(objectPath)}`;
610
+ const response = await fetch(url, {
611
+ method: "DELETE",
612
+ headers: this.authHeaders(),
613
+ }).catch(() => null);
614
+ if (!response || (!response.ok && response.status !== 404)) {
615
+ const body = await response?.text().catch(() => "");
616
+ throw new Error(`Supabase delete failed for '${name}'${body ? `: ${body}` : ""}`);
617
+ }
618
+ }
619
+ async pruneEmptyParentDirs(name) {
620
+ let relativeDir = path.posix.dirname(name);
621
+ while (relativeDir && relativeDir !== ".") {
622
+ if (!relativeDir.includes("/") && PROTECTED_ROOT_FOLDERS.has(relativeDir)) {
623
+ break;
624
+ }
625
+ const items = await this.listObjects(relativeDir).catch(() => []);
626
+ if (items.length > 0)
627
+ break;
628
+ const markerCandidates = [relativeDir, `${relativeDir}/`];
629
+ for (const marker of markerCandidates) {
630
+ await this.deleteObject(marker).catch(() => { });
631
+ }
632
+ const parent = path.posix.dirname(relativeDir);
633
+ if (parent === relativeDir)
634
+ break;
635
+ relativeDir = parent;
636
+ }
637
+ }
638
+ isDirectoryItem(item) {
639
+ return !item.id && !item.metadata;
640
+ }
641
+ parseModifiedTime(value) {
642
+ if (!value)
643
+ return new Date().toISOString();
644
+ const parsed = new Date(value);
645
+ if (Number.isNaN(parsed.getTime()))
646
+ return new Date().toISOString();
647
+ return parsed.toISOString();
648
+ }
649
+ cacheKeySegment() {
650
+ return this.sanitizeForCache(`${this.bucket}-${this.prefix || "root"}`);
651
+ }
652
+ sanitizeForCache(value) {
653
+ return value.replace(/[^a-z0-9._-]/gi, "_");
654
+ }
655
+ scheduleBackup(name, content) {
656
+ if (!this.backup)
657
+ return;
658
+ this.backup.backup(name, content).catch((err) => {
659
+ console.warn(`[backup] Failed to back up '${name}' to Drive:`, err);
660
+ });
661
+ }
662
+ }
663
+ // ─── Singleton factory ────────────────────────────────────────────────────────
664
+ let _client = null;
665
+ async function getOptionalDriveBackup() {
666
+ if (process.env.DRIVE_BACKUP_ENABLED !== "true")
667
+ return null;
668
+ try {
669
+ const { DriveBackup } = await import("../backup/drive.js");
670
+ return DriveBackup.tryCreate();
671
+ }
672
+ catch (err) {
673
+ // Drive backup not available or not configured — continue without it
674
+ console.warn("[backup] DriveBackup unavailable:", err instanceof Error ? err.message : String(err));
675
+ return null;
676
+ }
677
+ }
678
+ export async function getLocalClient() {
679
+ if (_client && _client instanceof LocalClient) {
680
+ return _client;
681
+ }
682
+ const wikiDir = process.env.WIKI_DIR ?? "./wiki";
683
+ const backup = await getOptionalDriveBackup();
684
+ const local = new LocalClient(wikiDir, backup);
685
+ _client = local;
686
+ return local;
687
+ }
688
+ export async function getStorageClient() {
689
+ if (_client)
690
+ return _client;
691
+ const backend = (process.env.STORAGE_BACKEND ?? "local").trim().toLowerCase();
692
+ if (backend === "supabase") {
693
+ const supabaseUrl = process.env.SUPABASE_URL ?? "";
694
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
695
+ const bucket = process.env.SUPABASE_STORAGE_BUCKET ?? "brains-wiki";
696
+ const prefix = process.env.SUPABASE_STORAGE_PREFIX ?? "";
697
+ const cacheRoot = process.env.WIKI_CACHE_DIR ?? path.join(".wiki-cache", "supabase");
698
+ const backup = await getOptionalDriveBackup();
699
+ _client = new SupabaseStorageClient({
700
+ supabaseUrl,
701
+ serviceRoleKey,
702
+ bucket,
703
+ prefix,
704
+ cacheLocation: path.join(cacheRoot, "root"),
705
+ backup,
706
+ });
707
+ return _client;
708
+ }
709
+ return getLocalClient();
710
+ }
711
+ export function createScopedStorageClient(baseClient, scopePrefix) {
712
+ if (!scopePrefix)
713
+ return baseClient;
714
+ if (baseClient instanceof LocalClient) {
715
+ const scopedRoot = path.join(baseClient.getStorageLocation(), scopePrefix);
716
+ return new LocalClient(scopedRoot);
717
+ }
718
+ if (baseClient instanceof SupabaseStorageClient) {
719
+ return baseClient.withPrefix(scopePrefix);
720
+ }
721
+ return baseClient;
722
+ }
723
+ function pathUnderPrefix(name, prefix) {
724
+ return name === prefix || name.startsWith(`${prefix}/`);
725
+ }
726
+ export class GrantAwareStorageClient {
727
+ ownClient;
728
+ grants;
729
+ ownSubject;
730
+ constructor(ownClient, grants, ownSubject) {
731
+ this.ownClient = ownClient;
732
+ this.grants = grants;
733
+ this.ownSubject = ownSubject;
734
+ }
735
+ resolve(name) {
736
+ const grant = this.grants.find((g) => pathUnderPrefix(name, g.prefix));
737
+ return grant ? grant.client : this.ownClient;
738
+ }
739
+ /**
740
+ * The true storage-owner subject for `name` — the grant owner's subject if
741
+ * the path falls under an active grant, otherwise the requester's own
742
+ * subject. Search-index writes must attribute to this, not the raw
743
+ * authenticated actor, or an owner never sees a grantee's contributions
744
+ * via list_pages/search_pages (BRA-381).
745
+ */
746
+ resolveOwnerSubject(name) {
747
+ const grant = this.grants.find((g) => pathUnderPrefix(name, g.prefix));
748
+ return grant ? grant.ownerSubject : this.ownSubject;
749
+ }
750
+ getStorageLocation() {
751
+ return this.ownClient.getStorageLocation();
752
+ }
753
+ async verifyAccess() {
754
+ await this.ownClient.verifyAccess();
755
+ }
756
+ async bootstrapWikiFiles() {
757
+ await this.ownClient.bootstrapWikiFiles();
758
+ }
759
+ fileExists(name) {
760
+ return this.resolve(name).fileExists(name);
761
+ }
762
+ async listFiles() {
763
+ const own = await this.listOwnFiles();
764
+ const merged = [...own];
765
+ const seen = new Set(own.map((f) => f.name));
766
+ const grantedFileLists = await Promise.all(this.grants.map((g) => g.client.listFiles()));
767
+ grantedFileLists.forEach((files, i) => {
768
+ const prefix = this.grants[i].prefix;
769
+ for (const f of files) {
770
+ // Defence in depth: only surface files actually inside the granted
771
+ // project — the owner's scoped client's root is their whole wiki.
772
+ if (!pathUnderPrefix(f.name, prefix) || seen.has(f.name))
773
+ continue;
774
+ seen.add(f.name);
775
+ merged.push(f);
776
+ }
777
+ });
778
+ return merged.sort((a, b) => a.name.localeCompare(b.name));
779
+ }
780
+ /**
781
+ * Own-namespace files only, excluding anything merged in from grants.
782
+ * audit_index compares this listing against `listAllPageNames(userId)`,
783
+ * which likewise only returns the actor's own index rows — the merged
784
+ * `listFiles()` would put grant-owner files on the Drive side with no
785
+ * counterpart on the index side, fabricating "unindexed" drift for shared
786
+ * pages the actor never actually owns (BRA-1857 / GH #453).
787
+ */
788
+ listOwnFiles() {
789
+ return this.ownClient.listFiles();
790
+ }
791
+ readFile(name) {
792
+ return this.resolve(name).readFile(name);
793
+ }
794
+ createFile(name, content) {
795
+ return this.resolve(name).createFile(name, content);
796
+ }
797
+ updateFile(fileId, content, seenRevision) {
798
+ return this.resolve(fileId).updateFile(fileId, content, seenRevision);
799
+ }
800
+ async renameFile(fileId, newName) {
801
+ const fromClient = this.resolve(fileId);
802
+ const toClient = this.resolve(newName);
803
+ if (fromClient !== toClient) {
804
+ throw new DrivePermissionError(`Cannot rename '${fileId}' to '${newName}': crosses a project-share boundary.`);
805
+ }
806
+ return fromClient.renameFile(fileId, newName);
807
+ }
808
+ deleteFile(name) {
809
+ return this.resolve(name).deleteFile(name);
810
+ }
811
+ }
812
+ /**
813
+ * Resolves the correct subject to attribute a Postgres search-index write to
814
+ * for `name`: the grant owner's subject when `drive` is grant-aware and
815
+ * `name` falls under an active grant, otherwise `actorUserId` unchanged.
816
+ * Use this — not the raw authenticated actor — for every index call
817
+ * (upsertPage, deletePage, fetchPageRow, syncPageLinks, etc.) so an owner can
818
+ * see a grantee's contributions via list_pages/search_pages (BRA-381).
819
+ */
820
+ export function resolveIndexOwnerSubject(drive, name, actorUserId) {
821
+ if (drive instanceof GrantAwareStorageClient)
822
+ return drive.resolveOwnerSubject(name);
823
+ return actorUserId;
824
+ }
825
+ //# sourceMappingURL=client.js.map