@karmaniverous/jeeves-server 3.0.0-0

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 (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. package/vitest.config.ts +13 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Link availability query endpoint.
3
+ * Returns what views and exports are available for a given path.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { getConfig } from '../../config/index.js';
8
+ import { getPlantUmlFormats } from '../../services/plantuml.js';
9
+ import { getRoots, urlPathToFs } from '../../util/platform.js';
10
+ // eslint-disable-next-line @typescript-eslint/require-await
11
+ export const linkInfoRoutes = async (fastify) => {
12
+ const roots = getRoots(getConfig().roots);
13
+ fastify.get('/api/link-info/*', async (request, reply) => {
14
+ const reqPath = request.params['*'];
15
+ if (!reqPath)
16
+ return reply.code(400).send({ error: 'Path required' });
17
+ const fsPath = urlPathToFs(reqPath, roots);
18
+ if (!fsPath)
19
+ return reply.code(404).send({ error: 'Invalid path' });
20
+ const resolved = path.resolve(fsPath);
21
+ let stats;
22
+ try {
23
+ stats = fs.statSync(resolved);
24
+ }
25
+ catch {
26
+ return reply.send({ exists: false });
27
+ }
28
+ const isDirectory = stats.isDirectory();
29
+ const ext = path.extname(resolved).toLowerCase();
30
+ const pageUrl = `/browse/${reqPath}`;
31
+ let rawUrl = null;
32
+ const exportLinks = [];
33
+ if (isDirectory) {
34
+ if (request.accessMode === 'insider') {
35
+ exportLinks.push({
36
+ format: 'zip',
37
+ url: `/api/export/${reqPath}?format=zip`,
38
+ });
39
+ }
40
+ }
41
+ else {
42
+ rawUrl = `/api/raw/${reqPath}`;
43
+ if (ext === '.md' || ext === '.markdown') {
44
+ exportLinks.push({ format: 'pdf', url: `/api/export/${reqPath}?format=pdf` }, { format: 'docx', url: `/api/export/${reqPath}?format=docx` });
45
+ }
46
+ else if (ext === '.mmd') {
47
+ for (const fmt of ['svg', 'png', 'pdf']) {
48
+ exportLinks.push({
49
+ format: fmt,
50
+ url: `/api/mermaid-export/${reqPath}?format=${fmt}`,
51
+ });
52
+ }
53
+ }
54
+ else if (['.puml', '.plantuml', '.pu'].includes(ext)) {
55
+ for (const fmt of getPlantUmlFormats()) {
56
+ exportLinks.push({
57
+ format: fmt,
58
+ url: `/api/plantuml-export/${reqPath}?format=${fmt}`,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return reply.send({
64
+ exists: true,
65
+ isDirectory,
66
+ pageUrl,
67
+ rawUrl,
68
+ exportLinks,
69
+ });
70
+ });
71
+ };
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ // Must set tmpDir before mocks reference it
6
+ let tmpDir;
7
+ vi.mock('../../config/index.js', () => ({
8
+ getConfig: () => ({ roots: {} }),
9
+ }));
10
+ vi.mock('../../services/plantuml.js', () => ({
11
+ getPlantUmlFormats: () => ['svg', 'png', 'pdf'],
12
+ }));
13
+ vi.mock('../../util/platform.js', () => ({
14
+ getRoots: () => ({}),
15
+ urlPathToFs: (reqPath) => {
16
+ // Simple mock: treat first segment as root name, rest as path
17
+ const parts = reqPath.split('/');
18
+ if (parts[0] === 'test')
19
+ return path.join(tmpDir, ...parts.slice(1));
20
+ return null;
21
+ },
22
+ }));
23
+ const { linkInfoRoutes } = await import('./linkInfo.js');
24
+ describe('GET /api/link-info', () => {
25
+ beforeEach(() => {
26
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-linkinfo-'));
27
+ });
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, { recursive: true, force: true });
30
+ });
31
+ async function callHandler(urlPath, accessMode = 'insider') {
32
+ const routes = {};
33
+ const fakeFastify = {
34
+ get: (routePath, handler) => {
35
+ routes[routePath] = handler;
36
+ },
37
+ };
38
+ await linkInfoRoutes(fakeFastify, {});
39
+ const handler = routes['/api/link-info/*'];
40
+ let result;
41
+ const fakeReply = {
42
+ code: () => ({
43
+ send: (d) => {
44
+ result = d;
45
+ return d;
46
+ },
47
+ }),
48
+ send: (d) => {
49
+ result = d;
50
+ return d;
51
+ },
52
+ };
53
+ const fakeRequest = {
54
+ params: { '*': urlPath },
55
+ accessMode,
56
+ };
57
+ await handler(fakeRequest, fakeReply);
58
+ return result;
59
+ }
60
+ it('returns exists: false for non-existent path', async () => {
61
+ const res = await callHandler('test/doesnotexist.md');
62
+ expect(res).toEqual({ exists: false });
63
+ });
64
+ it('returns directory links with ZIP for insiders', async () => {
65
+ fs.mkdirSync(path.join(tmpDir, 'folder'));
66
+ const res = await callHandler('test/folder');
67
+ expect(res.exists).toBe(true);
68
+ expect(res.isDirectory).toBe(true);
69
+ expect(res.rawUrl).toBe(null);
70
+ expect(res.exportLinks).toEqual([
71
+ { format: 'zip', url: '/api/export/test/folder?format=zip' },
72
+ ]);
73
+ });
74
+ it('omits ZIP for directories if outsider', async () => {
75
+ fs.mkdirSync(path.join(tmpDir, 'folder'));
76
+ const res = await callHandler('test/folder', 'outsider');
77
+ expect(res.exportLinks).toEqual([]);
78
+ });
79
+ it('returns markdown export links (pdf + docx)', async () => {
80
+ fs.writeFileSync(path.join(tmpDir, 'doc.md'), '# hello');
81
+ const res = await callHandler('test/doc.md');
82
+ expect(res.rawUrl).toBe('/api/raw/test/doc.md');
83
+ const links = res.exportLinks;
84
+ expect(links.map((l) => l.format)).toEqual(['pdf', 'docx']);
85
+ });
86
+ it('returns mermaid export links', async () => {
87
+ fs.writeFileSync(path.join(tmpDir, 'diag.mmd'), 'graph TD; A-->B');
88
+ const res = await callHandler('test/diag.mmd');
89
+ const links = res.exportLinks;
90
+ expect(links.map((l) => l.format)).toEqual(['svg', 'png', 'pdf']);
91
+ });
92
+ it('returns plantuml export links', async () => {
93
+ fs.writeFileSync(path.join(tmpDir, 'diag.puml'), '@startuml\nA->B\n@enduml');
94
+ const res = await callHandler('test/diag.puml');
95
+ const links = res.exportLinks;
96
+ expect(links.map((l) => l.format)).toEqual(['svg', 'png', 'pdf']);
97
+ });
98
+ it('returns no export links for plain files', async () => {
99
+ fs.writeFileSync(path.join(tmpDir, 'data.json'), '{}');
100
+ const res = await callHandler('test/data.json');
101
+ expect(res.rawUrl).toBe('/api/raw/test/data.json');
102
+ expect(res.exportLinks).toEqual([]);
103
+ });
104
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * API authentication middleware (preHandler hook).
3
+ */
4
+ import { resolveInsiderKeyAuth, resolveKeyAuth, resolveSessionAuth, } from '../../auth/resolve.js';
5
+ import { getConfig } from '../../config/index.js';
6
+ import { decodeStack } from '../../services/deepShareLinks.js';
7
+ /**
8
+ * Add the API auth preHandler hook directly to a Fastify instance.
9
+ * Must be called on the parent context (not via register()) so the hook
10
+ * applies to all sibling and child routes.
11
+ */
12
+ export function addAuthMiddleware(fastify) {
13
+ fastify.addHook('preHandler', async (request, reply) => {
14
+ if (!request.url.startsWith('/api'))
15
+ return;
16
+ if (request.url.startsWith('/api/readme-link'))
17
+ return;
18
+ if (request.url.startsWith('/api/content-link/'))
19
+ return;
20
+ if (request.url.startsWith('/api/auth/status'))
21
+ return;
22
+ if (request.url.startsWith('/api/diagram/'))
23
+ return;
24
+ const config = getConfig();
25
+ // Utility endpoints handle their own scope checking
26
+ if (request.url.startsWith('/api/util/')) {
27
+ const query = request.query;
28
+ // Try key-based auth
29
+ if (query.key) {
30
+ const keyResult = resolveKeyAuth(config, '/', query.key, query.exp);
31
+ if (keyResult.valid && keyResult.mode === 'insider') {
32
+ request.accessMode = 'insider';
33
+ request.authSeed = keyResult.seed;
34
+ request.insiderScopes = keyResult.scopes ?? null;
35
+ return;
36
+ }
37
+ // Try as a direct insider key
38
+ const insiderResult = resolveInsiderKeyAuth(config, query.key);
39
+ if (insiderResult.valid) {
40
+ request.accessMode = 'insider';
41
+ request.authSeed = insiderResult.seed;
42
+ request.insiderScopes = insiderResult.scopes ?? null;
43
+ request.insiderEmail = insiderResult.email;
44
+ return;
45
+ }
46
+ }
47
+ // Try session cookie
48
+ const sessionResult = resolveSessionAuth(config, request);
49
+ if (sessionResult.valid) {
50
+ request.accessMode = 'insider';
51
+ request.authSeed = sessionResult.seed;
52
+ request.insiderScopes = sessionResult.scopes ?? null;
53
+ request.insiderEmail = sessionResult.email;
54
+ return;
55
+ }
56
+ reply
57
+ .code(401)
58
+ .send({ error: 'Insider auth required for utility endpoints' });
59
+ return;
60
+ }
61
+ // General API auth
62
+ const query = request.query;
63
+ const deepParams = query.d !== undefined && query.s !== undefined
64
+ ? { d: query.d, dirs: query.dirs ?? '0', s: query.s }
65
+ : undefined;
66
+ const urlPath = request.url
67
+ .split('?')[0]
68
+ .replace('/api/path', '')
69
+ .replace('/api/drives', '/')
70
+ .replace('/api/file', '')
71
+ .replace('/api/raw', '')
72
+ .replace('/api/export-cache', '')
73
+ .replace('/api/export', '');
74
+ // Try key-based auth
75
+ let authResult = resolveKeyAuth(config, urlPath || '/', query.key, query.exp, deepParams);
76
+ // Retry with dirs fallback (directory shares)
77
+ if (!authResult.valid &&
78
+ deepParams &&
79
+ deepParams.dirs === '1' &&
80
+ query.key) {
81
+ const stack = decodeStack(deepParams.s);
82
+ const lastStackEntry = stack[stack.length - 1];
83
+ if (lastStackEntry && lastStackEntry !== urlPath) {
84
+ authResult = resolveKeyAuth(config, lastStackEntry, query.key, query.exp, deepParams);
85
+ }
86
+ }
87
+ // Try session cookie (always check — insiders visiting outsider links
88
+ // should be upgraded to insider access)
89
+ const sessionResult = resolveSessionAuth(config, request);
90
+ if (authResult.valid && sessionResult.valid) {
91
+ // Both key and session are valid — prefer insider session
92
+ request.accessMode = 'insider';
93
+ request.authSeed = sessionResult.seed;
94
+ request.insiderEmail = sessionResult.email;
95
+ request.insiderScopes = sessionResult.scopes ?? null;
96
+ request.keyAge = sessionResult.keyAge;
97
+ return;
98
+ }
99
+ if (authResult.valid) {
100
+ request.accessMode = authResult.mode;
101
+ request.authSeed = authResult.seed;
102
+ request.deepShareParams = authResult.deepShareParams;
103
+ request.authMatchedPath = authResult.matchedPath;
104
+ return;
105
+ }
106
+ if (sessionResult.valid) {
107
+ request.accessMode = 'insider';
108
+ request.authSeed = sessionResult.seed;
109
+ request.insiderEmail = sessionResult.email;
110
+ request.insiderScopes = sessionResult.scopes ?? null;
111
+ request.keyAge = sessionResult.keyAge;
112
+ return;
113
+ }
114
+ reply.code(401).send({ error: 'Unauthorized' });
115
+ return;
116
+ });
117
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Raw file serving API route.
3
+ *
4
+ * Handles: GET /api/raw/*
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { getConfig } from '../../config/index.js';
9
+ import { getContentType, isInlineType } from '../../util/fileDetection.js';
10
+ import { getRoots, urlPathToFs } from '../../util/platform.js';
11
+ // eslint-disable-next-line @typescript-eslint/require-await
12
+ export const rawRoutes = async (fastify) => {
13
+ const roots = getRoots(getConfig().roots);
14
+ fastify.get('/api/raw/*', async (request, reply) => {
15
+ const reqPath = request.params['*'];
16
+ if (!reqPath)
17
+ return reply.code(400).send({ error: 'Path required' });
18
+ const rawFsPath = urlPathToFs(reqPath, roots);
19
+ if (!rawFsPath)
20
+ return reply.code(404).send({ error: 'Invalid path' });
21
+ const resolved = path.resolve(rawFsPath);
22
+ if (!fs.existsSync(resolved))
23
+ return reply.code(404).send({ error: 'Not found', path: resolved });
24
+ const stats = fs.statSync(resolved);
25
+ if (stats.isDirectory()) {
26
+ return reply.code(400).send({
27
+ error: 'Cannot serve directory as raw — use /api/export for ZIP',
28
+ });
29
+ }
30
+ const ext = path.extname(resolved).toLowerCase();
31
+ const contentType = getContentType(ext);
32
+ reply.header('Content-Type', contentType);
33
+ if (!isInlineType(contentType)) {
34
+ reply.header('Content-Disposition', `attachment; filename="${path.basename(resolved)}"`);
35
+ }
36
+ return reply.send(fs.readFileSync(resolved));
37
+ });
38
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Runner proxy routes — proxies requests to the local jeeves-runner API.
3
+ *
4
+ * All routes require insider auth. The runner only listens on localhost,
5
+ * so jeeves-server acts as an authenticated gateway.
6
+ */
7
+ import { getConfig } from '../../config/index.js';
8
+ const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
9
+ function getRunnerUrl() {
10
+ return getConfig().runnerUrl ?? DEFAULT_RUNNER_URL;
11
+ }
12
+ /** Proxy a request to the runner and send the response via reply. */
13
+ async function proxyToRunner(reply, path, method = 'GET') {
14
+ try {
15
+ const res = await fetch(`${getRunnerUrl()}${path}`, { method });
16
+ const text = await res.text();
17
+ void reply
18
+ .code(res.status)
19
+ .header('content-type', res.headers.get('content-type') ?? 'application/json')
20
+ .send(text);
21
+ }
22
+ catch {
23
+ void reply.code(502).send(JSON.stringify({ error: 'Runner unreachable' }));
24
+ }
25
+ }
26
+ // eslint-disable-next-line @typescript-eslint/require-await
27
+ export const runnerRoutes = async (fastify) => {
28
+ fastify.addHook('preHandler', async (request, reply) => {
29
+ if (request.accessMode !== 'insider') {
30
+ return reply.code(403).send({ error: 'Insider access required' });
31
+ }
32
+ });
33
+ fastify.get('/api/runner/health', async (_req, reply) => {
34
+ await proxyToRunner(reply, '/health');
35
+ });
36
+ fastify.get('/api/runner/jobs', async (_req, reply) => {
37
+ await proxyToRunner(reply, '/jobs');
38
+ });
39
+ fastify.get('/api/runner/jobs/:id', async (req, reply) => {
40
+ await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
41
+ });
42
+ fastify.get('/api/runner/jobs/:id/runs', async (req, reply) => {
43
+ const limit = req.query.limit ?? '20';
44
+ const id = encodeURIComponent(req.params.id);
45
+ await proxyToRunner(reply, `/jobs/${id}/runs?limit=${limit}`);
46
+ });
47
+ fastify.post('/api/runner/jobs/:id/run', async (req, reply) => {
48
+ await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}/run`, 'POST');
49
+ });
50
+ fastify.post('/api/runner/jobs/:id/enable', async (req, reply) => {
51
+ await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}/enable`, 'POST');
52
+ });
53
+ fastify.post('/api/runner/jobs/:id/disable', async (req, reply) => {
54
+ await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}/disable`, 'POST');
55
+ });
56
+ fastify.get('/api/runner/stats', async (_req, reply) => {
57
+ await proxyToRunner(reply, '/stats');
58
+ });
59
+ };
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Search API route — proxies to jeeves-watcher for semantic search.
3
+ *
4
+ * Handles: POST /api/search
5
+ * Insider-only. Results filtered by insider's scope.
6
+ */
7
+ import { stat } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import picomatch from 'picomatch';
10
+ import { getConfig } from '../../config/index.js';
11
+ /** Check if a path passes the insider's scope rules. */
12
+ function pathAllowedByScope(urlPath, scopes) {
13
+ if (!scopes)
14
+ return true; // null = unrestricted
15
+ const normalized = urlPath.toLowerCase().replace(/\/+$/, '');
16
+ const allowMatch = picomatch(scopes.allow.map((p) => p.toLowerCase().replace(/\/+$/, '')));
17
+ if (!allowMatch(normalized))
18
+ return false;
19
+ if (scopes.deny.length > 0) {
20
+ const denyMatch = picomatch(scopes.deny.map((p) => p.toLowerCase().replace(/\/+$/, '')));
21
+ if (denyMatch(normalized))
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ /**
27
+ * Resolve a browse path back to a filesystem path using roots config.
28
+ * Inverse of fsPathToBrowsePath.
29
+ */
30
+ function browsePathToFsPath(browsePath, roots) {
31
+ const parts = browsePath.split('/');
32
+ const label = parts[0];
33
+ const rest = parts.slice(1).join('/');
34
+ // Check if label matches a root
35
+ if (roots[label]) {
36
+ return path.join(roots[label], rest);
37
+ }
38
+ // Windows drive letter: j/foo/bar → J:\foo\bar
39
+ if (/^[a-zA-Z]$/.test(label)) {
40
+ return `${label.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * Convert an absolute filesystem path to a browse URL path.
46
+ * Maps drive letters and root mounts back to the URL scheme.
47
+ */
48
+ function fsPathToBrowsePath(fsPath, roots) {
49
+ const normalized = fsPath.replace(/\\/g, '/');
50
+ // Windows drive letter: j:/foo/bar → j/foo/bar
51
+ const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
52
+ if (driveMatch) {
53
+ return `${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
54
+ }
55
+ // Linux roots: find matching root prefix
56
+ for (const [label, rootPath] of Object.entries(roots)) {
57
+ const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
58
+ if (normalized.startsWith(normalizedRoot + '/')) {
59
+ const relative = normalized.slice(normalizedRoot.length + 1);
60
+ return `${label}/${relative}`;
61
+ }
62
+ if (normalized === normalizedRoot) {
63
+ return label;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ // eslint-disable-next-line @typescript-eslint/require-await
69
+ export const searchRoutes = async (fastify) => {
70
+ fastify.post('/api/search', async (request, reply) => {
71
+ // Insider-only
72
+ if (request.accessMode !== 'insider') {
73
+ return reply.code(403).send({ error: 'Insider access required' });
74
+ }
75
+ const config = getConfig();
76
+ if (!config.watcherUrl) {
77
+ return reply.code(501).send({ error: 'Search not configured' });
78
+ }
79
+ const { query, limit = 20, filter } = request.body;
80
+ if (!query || typeof query !== 'string') {
81
+ return reply.code(400).send({ error: 'query is required' });
82
+ }
83
+ const insiderScopes = request.insiderScopes;
84
+ const roots = config.roots ?? {};
85
+ // Over-fetch to account for scope filtering
86
+ const fetchLimit = Math.min(limit * 5, 200);
87
+ try {
88
+ const watcherRes = await fetch(`${config.watcherUrl}/search`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ query, limit: fetchLimit, filter }),
92
+ });
93
+ if (!watcherRes.ok) {
94
+ const msg = await watcherRes.text().catch(() => '');
95
+ return await reply
96
+ .code(502)
97
+ .send({ error: `Watcher search failed: ${msg}` });
98
+ }
99
+ const rawResults = (await watcherRes.json());
100
+ // Filter by insider scope and map paths
101
+ const permitted = [];
102
+ for (const r of rawResults) {
103
+ const fp = r.payload.file_path;
104
+ if (!fp)
105
+ continue;
106
+ const browsePath = fsPathToBrowsePath(fp, roots);
107
+ if (!browsePath)
108
+ continue;
109
+ // Check insider scope
110
+ const urlPath = `/${browsePath}`;
111
+ if (!pathAllowedByScope(urlPath, insiderScopes ?? null))
112
+ continue;
113
+ permitted.push({ ...r, browsePath });
114
+ }
115
+ // Group by file path, take top `limit` files
116
+ const fileMap = new Map();
117
+ for (const r of permitted) {
118
+ const key = r.browsePath;
119
+ let group = fileMap.get(key);
120
+ if (!group) {
121
+ const parts = key.split('/');
122
+ group = {
123
+ filePath: r.payload.file_path ?? key,
124
+ browsePath: key,
125
+ fileName: parts[parts.length - 1],
126
+ bestScore: r.score,
127
+ domains: Array.isArray(r.payload.domains)
128
+ ? r.payload.domains
129
+ : r.payload.domain
130
+ ? [r.payload.domain]
131
+ : [],
132
+ title: r.payload.title,
133
+ author: r.payload.author,
134
+ participants: r.payload.participants,
135
+ chunks: [],
136
+ };
137
+ fileMap.set(key, group);
138
+ }
139
+ if (r.score > group.bestScore)
140
+ group.bestScore = r.score;
141
+ group.chunks.push({
142
+ text: r.payload.chunk_text ?? '',
143
+ index: r.payload.chunk_index ?? 0,
144
+ score: r.score,
145
+ });
146
+ }
147
+ // Sort by best score, limit
148
+ const grouped = [...fileMap.values()]
149
+ .sort((a, b) => b.bestScore - a.bestScore)
150
+ .slice(0, limit);
151
+ // Sort chunks within each group by index, and fetch mtime
152
+ await Promise.all(grouped.map(async (g) => {
153
+ g.chunks.sort((a, b) => a.index - b.index);
154
+ const fsPath = browsePathToFsPath(g.browsePath, roots);
155
+ if (fsPath) {
156
+ try {
157
+ const s = await stat(fsPath);
158
+ g.mtime = s.mtime.toISOString();
159
+ }
160
+ catch {
161
+ /* file may not be accessible */
162
+ }
163
+ }
164
+ }));
165
+ // Extract distinct metadata values for filter chips
166
+ const metadata = {
167
+ domains: [
168
+ ...new Set(grouped.flatMap((g) => g.domains || []).filter(Boolean)),
169
+ ],
170
+ authors: [...new Set(grouped.map((g) => g.author).filter(Boolean))],
171
+ participants: [
172
+ ...new Set(grouped.flatMap((g) => {
173
+ try {
174
+ const p = JSON.parse(g.participants ?? '[]');
175
+ return Array.isArray(p) ? p : [];
176
+ }
177
+ catch {
178
+ return g.participants ? [g.participants] : [];
179
+ }
180
+ })),
181
+ ].filter(Boolean),
182
+ };
183
+ return await reply.send({ results: grouped, metadata });
184
+ }
185
+ catch (err) {
186
+ return await reply
187
+ .code(502)
188
+ .send({ error: `Watcher unreachable: ${String(err)}` });
189
+ }
190
+ });
191
+ // Cached facets manifest
192
+ let facetsCache = null;
193
+ let facetsFetchPromise = null;
194
+ const FACETS_CACHE_TTL_MS = 60_000; // 1 minute
195
+ fastify.get('/api/search/facets', async (request, reply) => {
196
+ if (request.accessMode !== 'insider') {
197
+ return reply.code(403).send({ error: 'Insider access required' });
198
+ }
199
+ const config = getConfig();
200
+ if (!config.watcherUrl) {
201
+ return reply.code(501).send({ error: 'Search not configured' });
202
+ }
203
+ // Return cached if fresh
204
+ if (facetsCache &&
205
+ Date.now() - facetsCache.fetchedAt < FACETS_CACHE_TTL_MS) {
206
+ return reply.send(facetsCache.data);
207
+ }
208
+ try {
209
+ // Guard against cache stampede: reuse in-flight fetch
210
+ if (!facetsFetchPromise) {
211
+ const watcherUrl = config.watcherUrl;
212
+ facetsFetchPromise = (async () => {
213
+ const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
214
+ signal: AbortSignal.timeout(5000),
215
+ });
216
+ if (!watcherRes.ok) {
217
+ throw new Error(`HTTP ${String(watcherRes.status)}`);
218
+ }
219
+ const data = await watcherRes.json();
220
+ facetsCache = { data, fetchedAt: Date.now() };
221
+ return data;
222
+ })().finally(() => {
223
+ facetsFetchPromise = null;
224
+ });
225
+ }
226
+ const data = await facetsFetchPromise;
227
+ return await reply.send(data);
228
+ }
229
+ catch (err) {
230
+ return await reply.code(502).send({
231
+ error: 'Failed to reach watcher',
232
+ detail: err instanceof Error ? err.message : String(err),
233
+ });
234
+ }
235
+ });
236
+ };