@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,156 @@
1
+ /**
2
+ * API authentication middleware (preHandler hook).
3
+ */
4
+
5
+ import type { FastifyInstance } from 'fastify';
6
+
7
+ import {
8
+ resolveInsiderKeyAuth,
9
+ resolveKeyAuth,
10
+ resolveSessionAuth,
11
+ } from '../../auth/resolve.js';
12
+ import { getConfig } from '../../config/index.js';
13
+ import { decodeStack } from '../../services/deepShareLinks.js';
14
+
15
+ /**
16
+ * Add the API auth preHandler hook directly to a Fastify instance.
17
+ * Must be called on the parent context (not via register()) so the hook
18
+ * applies to all sibling and child routes.
19
+ */
20
+ export function addAuthMiddleware(fastify: FastifyInstance): void {
21
+ fastify.addHook('preHandler', async (request, reply) => {
22
+ if (!request.url.startsWith('/api')) return;
23
+ if (request.url.startsWith('/api/readme-link')) return;
24
+ if (request.url.startsWith('/api/content-link/')) return;
25
+ if (request.url.startsWith('/api/auth/status')) return;
26
+ if (request.url.startsWith('/api/diagram/')) return;
27
+
28
+ const config = getConfig();
29
+
30
+ // Utility endpoints handle their own scope checking
31
+ if (request.url.startsWith('/api/util/')) {
32
+ const query = request.query as { key?: string; exp?: string };
33
+
34
+ // Try key-based auth
35
+ if (query.key) {
36
+ const keyResult = resolveKeyAuth(config, '/', query.key, query.exp);
37
+ if (keyResult.valid && keyResult.mode === 'insider') {
38
+ request.accessMode = 'insider';
39
+ request.authSeed = keyResult.seed;
40
+ request.insiderScopes = keyResult.scopes ?? null;
41
+ return;
42
+ }
43
+
44
+ // Try as a direct insider key
45
+ const insiderResult = resolveInsiderKeyAuth(config, query.key);
46
+ if (insiderResult.valid) {
47
+ request.accessMode = 'insider';
48
+ request.authSeed = insiderResult.seed;
49
+ request.insiderScopes = insiderResult.scopes ?? null;
50
+ request.insiderEmail = insiderResult.email;
51
+ return;
52
+ }
53
+ }
54
+
55
+ // Try session cookie
56
+ const sessionResult = resolveSessionAuth(config, request);
57
+ if (sessionResult.valid) {
58
+ request.accessMode = 'insider';
59
+ request.authSeed = sessionResult.seed;
60
+ request.insiderScopes = sessionResult.scopes ?? null;
61
+ request.insiderEmail = sessionResult.email;
62
+ return;
63
+ }
64
+
65
+ reply
66
+ .code(401)
67
+ .send({ error: 'Insider auth required for utility endpoints' });
68
+ return;
69
+ }
70
+
71
+ // General API auth
72
+ const query = request.query as {
73
+ key?: string;
74
+ exp?: string;
75
+ d?: string;
76
+ dirs?: string;
77
+ s?: string;
78
+ };
79
+ const deepParams =
80
+ query.d !== undefined && query.s !== undefined
81
+ ? { d: query.d, dirs: query.dirs ?? '0', s: query.s }
82
+ : undefined;
83
+
84
+ const urlPath = request.url
85
+ .split('?')[0]
86
+ .replace('/api/path', '')
87
+ .replace('/api/drives', '/')
88
+ .replace('/api/file', '')
89
+ .replace('/api/raw', '')
90
+ .replace('/api/export-cache', '')
91
+ .replace('/api/export', '');
92
+
93
+ // Try key-based auth
94
+ let authResult = resolveKeyAuth(
95
+ config,
96
+ urlPath || '/',
97
+ query.key,
98
+ query.exp,
99
+ deepParams,
100
+ );
101
+
102
+ // Retry with dirs fallback (directory shares)
103
+ if (
104
+ !authResult.valid &&
105
+ deepParams &&
106
+ deepParams.dirs === '1' &&
107
+ query.key
108
+ ) {
109
+ const stack = decodeStack(deepParams.s);
110
+ const lastStackEntry = stack[stack.length - 1];
111
+ if (lastStackEntry && lastStackEntry !== urlPath) {
112
+ authResult = resolveKeyAuth(
113
+ config,
114
+ lastStackEntry,
115
+ query.key,
116
+ query.exp,
117
+ deepParams,
118
+ );
119
+ }
120
+ }
121
+
122
+ // Try session cookie (always check — insiders visiting outsider links
123
+ // should be upgraded to insider access)
124
+ const sessionResult = resolveSessionAuth(config, request);
125
+
126
+ if (authResult.valid && sessionResult.valid) {
127
+ // Both key and session are valid — prefer insider session
128
+ request.accessMode = 'insider';
129
+ request.authSeed = sessionResult.seed;
130
+ request.insiderEmail = sessionResult.email;
131
+ request.insiderScopes = sessionResult.scopes ?? null;
132
+ request.keyAge = sessionResult.keyAge;
133
+ return;
134
+ }
135
+
136
+ if (authResult.valid) {
137
+ request.accessMode = authResult.mode;
138
+ request.authSeed = authResult.seed;
139
+ request.deepShareParams = authResult.deepShareParams;
140
+ request.authMatchedPath = authResult.matchedPath;
141
+ return;
142
+ }
143
+
144
+ if (sessionResult.valid) {
145
+ request.accessMode = 'insider';
146
+ request.authSeed = sessionResult.seed;
147
+ request.insiderEmail = sessionResult.email;
148
+ request.insiderScopes = sessionResult.scopes ?? null;
149
+ request.keyAge = sessionResult.keyAge;
150
+ return;
151
+ }
152
+
153
+ reply.code(401).send({ error: 'Unauthorized' });
154
+ return;
155
+ });
156
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Raw file serving API route.
3
+ *
4
+ * Handles: GET /api/raw/*
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import type { FastifyPluginAsync } from 'fastify';
11
+
12
+ import { getConfig } from '../../config/index.js';
13
+ import { getContentType, isInlineType } from '../../util/fileDetection.js';
14
+ import { getRoots, urlPathToFs } from '../../util/platform.js';
15
+
16
+ // eslint-disable-next-line @typescript-eslint/require-await
17
+ export const rawRoutes: FastifyPluginAsync = async (fastify) => {
18
+ const roots = getRoots(getConfig().roots);
19
+
20
+ fastify.get<{ Params: { '*': string } }>(
21
+ '/api/raw/*',
22
+ async (request, reply) => {
23
+ const reqPath = request.params['*'];
24
+ if (!reqPath) return reply.code(400).send({ error: 'Path required' });
25
+
26
+ const rawFsPath = urlPathToFs(reqPath, roots);
27
+ if (!rawFsPath) return reply.code(404).send({ error: 'Invalid path' });
28
+ const resolved = path.resolve(rawFsPath);
29
+
30
+ if (!fs.existsSync(resolved))
31
+ return reply.code(404).send({ error: 'Not found', path: resolved });
32
+
33
+ const stats = fs.statSync(resolved);
34
+ if (stats.isDirectory()) {
35
+ return reply.code(400).send({
36
+ error: 'Cannot serve directory as raw — use /api/export for ZIP',
37
+ });
38
+ }
39
+
40
+ const ext = path.extname(resolved).toLowerCase();
41
+ const contentType = getContentType(ext);
42
+ reply.header('Content-Type', contentType);
43
+
44
+ if (!isInlineType(contentType)) {
45
+ reply.header(
46
+ 'Content-Disposition',
47
+ `attachment; filename="${path.basename(resolved)}"`,
48
+ );
49
+ }
50
+
51
+ return reply.send(fs.readFileSync(resolved));
52
+ },
53
+ );
54
+ };
@@ -0,0 +1,107 @@
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
+
8
+ import type { FastifyPluginAsync, FastifyReply } from 'fastify';
9
+
10
+ import { getConfig } from '../../config/index.js';
11
+
12
+ const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
13
+
14
+ function getRunnerUrl(): string {
15
+ return getConfig().runnerUrl ?? DEFAULT_RUNNER_URL;
16
+ }
17
+
18
+ /** Proxy a request to the runner and send the response via reply. */
19
+ async function proxyToRunner(
20
+ reply: FastifyReply,
21
+ path: string,
22
+ method: 'GET' | 'POST' = 'GET',
23
+ ): Promise<void> {
24
+ try {
25
+ const res = await fetch(`${getRunnerUrl()}${path}`, { method });
26
+ const text = await res.text();
27
+ void reply
28
+ .code(res.status)
29
+ .header(
30
+ 'content-type',
31
+ res.headers.get('content-type') ?? 'application/json',
32
+ )
33
+ .send(text);
34
+ } catch {
35
+ void reply.code(502).send(JSON.stringify({ error: 'Runner unreachable' }));
36
+ }
37
+ }
38
+
39
+ // eslint-disable-next-line @typescript-eslint/require-await
40
+ export const runnerRoutes: FastifyPluginAsync = async (fastify) => {
41
+ fastify.addHook('preHandler', async (request, reply) => {
42
+ if (request.accessMode !== 'insider') {
43
+ return reply.code(403).send({ error: 'Insider access required' });
44
+ }
45
+ });
46
+
47
+ fastify.get('/api/runner/health', async (_req, reply) => {
48
+ await proxyToRunner(reply, '/health');
49
+ });
50
+
51
+ fastify.get('/api/runner/jobs', async (_req, reply) => {
52
+ await proxyToRunner(reply, '/jobs');
53
+ });
54
+
55
+ fastify.get<{ Params: { id: string } }>(
56
+ '/api/runner/jobs/:id',
57
+ async (req, reply) => {
58
+ await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
59
+ },
60
+ );
61
+
62
+ fastify.get<{ Params: { id: string }; Querystring: { limit?: string } }>(
63
+ '/api/runner/jobs/:id/runs',
64
+ async (req, reply) => {
65
+ const limit = req.query.limit ?? '20';
66
+ const id = encodeURIComponent(req.params.id);
67
+ await proxyToRunner(reply, `/jobs/${id}/runs?limit=${limit}`);
68
+ },
69
+ );
70
+
71
+ fastify.post<{ Params: { id: string } }>(
72
+ '/api/runner/jobs/:id/run',
73
+ async (req, reply) => {
74
+ await proxyToRunner(
75
+ reply,
76
+ `/jobs/${encodeURIComponent(req.params.id)}/run`,
77
+ 'POST',
78
+ );
79
+ },
80
+ );
81
+
82
+ fastify.post<{ Params: { id: string } }>(
83
+ '/api/runner/jobs/:id/enable',
84
+ async (req, reply) => {
85
+ await proxyToRunner(
86
+ reply,
87
+ `/jobs/${encodeURIComponent(req.params.id)}/enable`,
88
+ 'POST',
89
+ );
90
+ },
91
+ );
92
+
93
+ fastify.post<{ Params: { id: string } }>(
94
+ '/api/runner/jobs/:id/disable',
95
+ async (req, reply) => {
96
+ await proxyToRunner(
97
+ reply,
98
+ `/jobs/${encodeURIComponent(req.params.id)}/disable`,
99
+ 'POST',
100
+ );
101
+ },
102
+ );
103
+
104
+ fastify.get('/api/runner/stats', async (_req, reply) => {
105
+ await proxyToRunner(reply, '/stats');
106
+ });
107
+ };
@@ -0,0 +1,321 @@
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
+
8
+ import { stat } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ import type { FastifyPluginAsync } from 'fastify';
12
+ import picomatch from 'picomatch';
13
+
14
+ import { getConfig } from '../../config/index.js';
15
+ import type { NormalizedScopes } from '../../config/types.js';
16
+
17
+ /** Check if a path passes the insider's scope rules. */
18
+ function pathAllowedByScope(
19
+ urlPath: string,
20
+ scopes: NormalizedScopes | null,
21
+ ): boolean {
22
+ if (!scopes) return true; // null = unrestricted
23
+ const normalized = urlPath.toLowerCase().replace(/\/+$/, '');
24
+ const allowMatch = picomatch(
25
+ scopes.allow.map((p) => p.toLowerCase().replace(/\/+$/, '')),
26
+ );
27
+ if (!allowMatch(normalized)) return false;
28
+ if (scopes.deny.length > 0) {
29
+ const denyMatch = picomatch(
30
+ scopes.deny.map((p) => p.toLowerCase().replace(/\/+$/, '')),
31
+ );
32
+ if (denyMatch(normalized)) return false;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ interface WatcherResult {
38
+ id: string;
39
+ score: number;
40
+ payload: {
41
+ file_path?: string;
42
+ chunk_text?: string;
43
+ chunk_index?: number;
44
+ total_chunks?: number;
45
+ domains?: string[];
46
+ title?: string;
47
+ author?: string;
48
+ participants?: string;
49
+ content_hash?: string;
50
+ [key: string]: unknown;
51
+ };
52
+ }
53
+
54
+ interface GroupedResult {
55
+ filePath: string;
56
+ browsePath: string;
57
+ fileName: string;
58
+ bestScore: number;
59
+ mtime?: string;
60
+ domains?: string[];
61
+ title?: string;
62
+ author?: string;
63
+ participants?: string;
64
+ chunks: Array<{
65
+ text: string;
66
+ index: number;
67
+ score: number;
68
+ }>;
69
+ }
70
+
71
+ /**
72
+ * Resolve a browse path back to a filesystem path using roots config.
73
+ * Inverse of fsPathToBrowsePath.
74
+ */
75
+ function browsePathToFsPath(
76
+ browsePath: string,
77
+ roots: Record<string, string>,
78
+ ): string | null {
79
+ const parts = browsePath.split('/');
80
+ const label = parts[0];
81
+ const rest = parts.slice(1).join('/');
82
+
83
+ // Check if label matches a root
84
+ if (roots[label]) {
85
+ return path.join(roots[label], rest);
86
+ }
87
+
88
+ // Windows drive letter: j/foo/bar → J:\foo\bar
89
+ if (/^[a-zA-Z]$/.test(label)) {
90
+ return `${label.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Convert an absolute filesystem path to a browse URL path.
98
+ * Maps drive letters and root mounts back to the URL scheme.
99
+ */
100
+ function fsPathToBrowsePath(
101
+ fsPath: string,
102
+ roots: Record<string, string>,
103
+ ): string | null {
104
+ const normalized = fsPath.replace(/\\/g, '/');
105
+
106
+ // Windows drive letter: j:/foo/bar → j/foo/bar
107
+ const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
108
+ if (driveMatch) {
109
+ return `${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
110
+ }
111
+
112
+ // Linux roots: find matching root prefix
113
+ for (const [label, rootPath] of Object.entries(roots)) {
114
+ const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
115
+ if (normalized.startsWith(normalizedRoot + '/')) {
116
+ const relative = normalized.slice(normalizedRoot.length + 1);
117
+ return `${label}/${relative}`;
118
+ }
119
+ if (normalized === normalizedRoot) {
120
+ return label;
121
+ }
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ // eslint-disable-next-line @typescript-eslint/require-await
128
+ export const searchRoutes: FastifyPluginAsync = async (fastify) => {
129
+ fastify.post<{
130
+ Body: {
131
+ query: string;
132
+ limit?: number;
133
+ filter?: Record<string, unknown>;
134
+ };
135
+ }>('/api/search', async (request, reply) => {
136
+ // Insider-only
137
+ if (request.accessMode !== 'insider') {
138
+ return reply.code(403).send({ error: 'Insider access required' });
139
+ }
140
+
141
+ const config = getConfig();
142
+ if (!config.watcherUrl) {
143
+ return reply.code(501).send({ error: 'Search not configured' });
144
+ }
145
+
146
+ const { query, limit = 20, filter } = request.body;
147
+ if (!query || typeof query !== 'string') {
148
+ return reply.code(400).send({ error: 'query is required' });
149
+ }
150
+
151
+ const insiderScopes = request.insiderScopes;
152
+ const roots = config.roots ?? {};
153
+
154
+ // Over-fetch to account for scope filtering
155
+ const fetchLimit = Math.min(limit * 5, 200);
156
+
157
+ try {
158
+ const watcherRes = await fetch(`${config.watcherUrl}/search`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ query, limit: fetchLimit, filter }),
162
+ });
163
+
164
+ if (!watcherRes.ok) {
165
+ const msg = await watcherRes.text().catch(() => '');
166
+ return await reply
167
+ .code(502)
168
+ .send({ error: `Watcher search failed: ${msg}` });
169
+ }
170
+
171
+ const rawResults = (await watcherRes.json()) as WatcherResult[];
172
+
173
+ // Filter by insider scope and map paths
174
+ const permitted: Array<WatcherResult & { browsePath: string }> = [];
175
+ for (const r of rawResults) {
176
+ const fp = r.payload.file_path;
177
+ if (!fp) continue;
178
+
179
+ const browsePath = fsPathToBrowsePath(fp, roots);
180
+ if (!browsePath) continue;
181
+
182
+ // Check insider scope
183
+ const urlPath = `/${browsePath}`;
184
+ if (!pathAllowedByScope(urlPath, insiderScopes ?? null)) continue;
185
+
186
+ permitted.push({ ...r, browsePath });
187
+ }
188
+
189
+ // Group by file path, take top `limit` files
190
+ const fileMap = new Map<string, GroupedResult>();
191
+ for (const r of permitted) {
192
+ const key = r.browsePath;
193
+ let group = fileMap.get(key);
194
+ if (!group) {
195
+ const parts = key.split('/');
196
+ group = {
197
+ filePath: r.payload.file_path ?? key,
198
+ browsePath: key,
199
+ fileName: parts[parts.length - 1],
200
+ bestScore: r.score,
201
+ domains: Array.isArray(r.payload.domains)
202
+ ? r.payload.domains
203
+ : r.payload.domain
204
+ ? [r.payload.domain as string]
205
+ : [],
206
+ title: r.payload.title,
207
+ author: r.payload.author,
208
+ participants: r.payload.participants,
209
+ chunks: [],
210
+ };
211
+ fileMap.set(key, group);
212
+ }
213
+ if (r.score > group.bestScore) group.bestScore = r.score;
214
+ group.chunks.push({
215
+ text: r.payload.chunk_text ?? '',
216
+ index: r.payload.chunk_index ?? 0,
217
+ score: r.score,
218
+ });
219
+ }
220
+
221
+ // Sort by best score, limit
222
+ const grouped = [...fileMap.values()]
223
+ .sort((a, b) => b.bestScore - a.bestScore)
224
+ .slice(0, limit);
225
+
226
+ // Sort chunks within each group by index, and fetch mtime
227
+ await Promise.all(
228
+ grouped.map(async (g) => {
229
+ g.chunks.sort((a, b) => a.index - b.index);
230
+ const fsPath = browsePathToFsPath(g.browsePath, roots);
231
+ if (fsPath) {
232
+ try {
233
+ const s = await stat(fsPath);
234
+ g.mtime = s.mtime.toISOString();
235
+ } catch {
236
+ /* file may not be accessible */
237
+ }
238
+ }
239
+ }),
240
+ );
241
+
242
+ // Extract distinct metadata values for filter chips
243
+ const metadata = {
244
+ domains: [
245
+ ...new Set(grouped.flatMap((g) => g.domains || []).filter(Boolean)),
246
+ ],
247
+ authors: [...new Set(grouped.map((g) => g.author).filter(Boolean))],
248
+ participants: [
249
+ ...new Set(
250
+ grouped.flatMap((g) => {
251
+ try {
252
+ const p = JSON.parse(g.participants ?? '[]') as string[];
253
+ return Array.isArray(p) ? p : [];
254
+ } catch {
255
+ return g.participants ? [g.participants] : [];
256
+ }
257
+ }),
258
+ ),
259
+ ].filter(Boolean),
260
+ };
261
+
262
+ return await reply.send({ results: grouped, metadata });
263
+ } catch (err) {
264
+ return await reply
265
+ .code(502)
266
+ .send({ error: `Watcher unreachable: ${String(err)}` });
267
+ }
268
+ });
269
+
270
+ // Cached facets manifest
271
+ let facetsCache: { data: unknown; fetchedAt: number } | null = null;
272
+ let facetsFetchPromise: Promise<unknown> | null = null;
273
+ const FACETS_CACHE_TTL_MS = 60_000; // 1 minute
274
+
275
+ fastify.get('/api/search/facets', async (request, reply) => {
276
+ if (request.accessMode !== 'insider') {
277
+ return reply.code(403).send({ error: 'Insider access required' });
278
+ }
279
+
280
+ const config = getConfig();
281
+ if (!config.watcherUrl) {
282
+ return reply.code(501).send({ error: 'Search not configured' });
283
+ }
284
+
285
+ // Return cached if fresh
286
+ if (
287
+ facetsCache &&
288
+ Date.now() - facetsCache.fetchedAt < FACETS_CACHE_TTL_MS
289
+ ) {
290
+ return reply.send(facetsCache.data);
291
+ }
292
+
293
+ try {
294
+ // Guard against cache stampede: reuse in-flight fetch
295
+ if (!facetsFetchPromise) {
296
+ const watcherUrl = config.watcherUrl;
297
+ facetsFetchPromise = (async () => {
298
+ const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
299
+ signal: AbortSignal.timeout(5000),
300
+ });
301
+ if (!watcherRes.ok) {
302
+ throw new Error(`HTTP ${String(watcherRes.status)}`);
303
+ }
304
+ const data: unknown = await watcherRes.json();
305
+ facetsCache = { data, fetchedAt: Date.now() };
306
+ return data;
307
+ })().finally(() => {
308
+ facetsFetchPromise = null;
309
+ });
310
+ }
311
+
312
+ const data = await facetsFetchPromise;
313
+ return await reply.send(data);
314
+ } catch (err) {
315
+ return await reply.code(502).send({
316
+ error: 'Failed to reach watcher',
317
+ detail: err instanceof Error ? err.message : String(err),
318
+ });
319
+ }
320
+ });
321
+ };