@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,79 @@
1
+ /**
2
+ * Cryptographic utilities for key computation and verification
3
+ */
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ /**
8
+ * Compute path-specific key: HMAC-SHA256(apiKey, normalizedPath)
9
+ */
10
+ export function computePathKey(apiKey: string, urlPath: string): string {
11
+ const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
12
+ const hash = crypto
13
+ .createHmac('sha256', apiKey)
14
+ .update(normalized)
15
+ .digest('hex');
16
+ return hash.substring(0, 32);
17
+ }
18
+
19
+ /**
20
+ * Compute insider key: HMAC-SHA256(apiKey, "insider")
21
+ * Works for any path, grants full navigation
22
+ */
23
+ export function computeInsiderKey(apiKey: string): string {
24
+ const hash = crypto
25
+ .createHmac('sha256', apiKey)
26
+ .update('insider')
27
+ .digest('hex');
28
+ return hash.substring(0, 32);
29
+ }
30
+
31
+ /**
32
+ * Compute outsider key with expiry: HMAC-SHA256(apiKey, path + "|" + expiry)
33
+ */
34
+ export function computeOutsiderKeyWithExpiry(
35
+ apiKey: string,
36
+ urlPath: string,
37
+ expiry: string | number,
38
+ ): string {
39
+ const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
40
+ const data = `${normalized}|${String(expiry)}`;
41
+ const hash = crypto.createHmac('sha256', apiKey).update(data).digest('hex');
42
+ return hash.substring(0, 32);
43
+ }
44
+
45
+ /**
46
+ * Deep share key parameters
47
+ */
48
+ export interface DeepShareParams {
49
+ depth: number;
50
+ dirs: boolean;
51
+ stack: string; // lz-string compressToEncodedURIComponent'd path stack
52
+ exp?: string;
53
+ }
54
+
55
+ /**
56
+ * Compute deep share key: HMAC-SHA256(seed, path + "|" + d + "|" + dirs + "|" + stack + "|" + exp)
57
+ * Used for share links with depth \> 0 or dirs enabled.
58
+ */
59
+ export function computeDeepShareKey(
60
+ seed: string,
61
+ urlPath: string,
62
+ params: DeepShareParams,
63
+ ): string {
64
+ const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
65
+ const data = `${normalized}|${String(params.depth)}|${params.dirs ? '1' : '0'}|${params.stack}|${params.exp ?? ''}`;
66
+ const hash = crypto.createHmac('sha256', seed).update(data).digest('hex');
67
+ return hash.substring(0, 32);
68
+ }
69
+
70
+ /**
71
+ * Timing-safe string comparison
72
+ */
73
+ export function timingSafeEqual(a: string, b: string): boolean {
74
+ try {
75
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tests for file detection utilities
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import {
8
+ getContentType,
9
+ isInlineType,
10
+ looksLikeText,
11
+ } from './fileDetection.js';
12
+
13
+ describe('file detection', () => {
14
+ describe('looksLikeText', () => {
15
+ it('should return true for plain text', () => {
16
+ const buffer = Buffer.from('Hello, world!', 'utf8');
17
+ expect(looksLikeText(buffer)).toBe(true);
18
+ });
19
+
20
+ it('should return true for text with newlines', () => {
21
+ const buffer = Buffer.from('Line 1\nLine 2\nLine 3', 'utf8');
22
+ expect(looksLikeText(buffer)).toBe(true);
23
+ });
24
+
25
+ it('should return false for binary data with null bytes', () => {
26
+ const buffer = Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6c, 0x6f]); // He\0llo
27
+ expect(looksLikeText(buffer)).toBe(false);
28
+ });
29
+
30
+ it('should return false for binary data at start', () => {
31
+ const buffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
32
+ expect(looksLikeText(buffer)).toBe(false);
33
+ });
34
+
35
+ it('should only check first 8KB', () => {
36
+ // Create 10KB buffer with null byte at position 9000
37
+ const buffer = Buffer.alloc(10240);
38
+ buffer.fill('A'.charCodeAt(0), 0, 9000);
39
+ buffer[9000] = 0;
40
+ buffer.fill('A'.charCodeAt(0), 9001);
41
+
42
+ // Should return true because null byte is beyond 8KB
43
+ expect(looksLikeText(buffer)).toBe(true);
44
+ });
45
+
46
+ it('should handle empty buffer', () => {
47
+ const buffer = Buffer.alloc(0);
48
+ expect(looksLikeText(buffer)).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('getContentType', () => {
53
+ it('should return correct type for text files', () => {
54
+ expect(getContentType('.txt')).toBe('text/plain; charset=utf-8');
55
+ expect(getContentType('.json')).toBe('application/json; charset=utf-8');
56
+ expect(getContentType('.html')).toBe('text/html; charset=utf-8');
57
+ });
58
+
59
+ it('should return correct type for images', () => {
60
+ expect(getContentType('.png')).toBe('image/png');
61
+ expect(getContentType('.jpg')).toBe('image/jpeg');
62
+ expect(getContentType('.svg')).toBe('image/svg+xml');
63
+ });
64
+
65
+ it('should return correct type for documents', () => {
66
+ expect(getContentType('.pdf')).toBe('application/pdf');
67
+ expect(getContentType('.docx')).toBe(
68
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
69
+ );
70
+ });
71
+
72
+ it('should be case-insensitive', () => {
73
+ expect(getContentType('.PDF')).toBe('application/pdf');
74
+ expect(getContentType('.TxT')).toBe('text/plain; charset=utf-8');
75
+ });
76
+
77
+ it('should return octet-stream for unknown extensions', () => {
78
+ expect(getContentType('.unknown')).toBe('application/octet-stream');
79
+ expect(getContentType('.xyz123')).toBe('application/octet-stream');
80
+ });
81
+ });
82
+
83
+ describe('isInlineType', () => {
84
+ it('should return true for text types', () => {
85
+ expect(isInlineType('text/plain; charset=utf-8')).toBe(true);
86
+ expect(isInlineType('text/html')).toBe(true);
87
+ });
88
+
89
+ it('should return true for images', () => {
90
+ expect(isInlineType('image/png')).toBe(true);
91
+ expect(isInlineType('image/jpeg')).toBe(true);
92
+ });
93
+
94
+ it('should return true for video/audio', () => {
95
+ expect(isInlineType('video/mp4')).toBe(true);
96
+ expect(isInlineType('audio/mpeg')).toBe(true);
97
+ });
98
+
99
+ it('should return true for JSON and PDF', () => {
100
+ expect(isInlineType('application/json; charset=utf-8')).toBe(true);
101
+ expect(isInlineType('application/pdf')).toBe(true);
102
+ expect(isInlineType('application/xml')).toBe(true);
103
+ });
104
+
105
+ it('should return false for archives', () => {
106
+ expect(isInlineType('application/zip')).toBe(false);
107
+ expect(isInlineType('application/x-tar')).toBe(false);
108
+ });
109
+
110
+ it('should return false for executables', () => {
111
+ expect(isInlineType('application/octet-stream')).toBe(false);
112
+ expect(isInlineType('application/x-msdownload')).toBe(false);
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * File type detection and content-type mapping
3
+ */
4
+
5
+ import mime from 'mime-types';
6
+
7
+ /**
8
+ * Override map for extensions where mime-types returns an incorrect or
9
+ * unhelpful result for our use case.
10
+ */
11
+ const OVERRIDES: Record<string, string> = {
12
+ '.mmd': 'text/plain; charset=utf-8',
13
+ '.puml': 'text/plain; charset=utf-8',
14
+ '.plantuml': 'text/plain; charset=utf-8',
15
+ '.pu': 'text/plain; charset=utf-8',
16
+ '.log': 'text/plain; charset=utf-8',
17
+ '.jsonl': 'text/plain; charset=utf-8',
18
+ };
19
+
20
+ /**
21
+ * Detect if a buffer looks like text (no null bytes in first 8KB)
22
+ */
23
+ export function looksLikeText(buffer: Buffer): boolean {
24
+ const checkSize = Math.min(buffer.length, 8192);
25
+ for (let i = 0; i < checkSize; i++) {
26
+ if (buffer[i] === 0) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Get content type for a file extension (with leading dot, e.g. '.md')
33
+ */
34
+ export function getContentType(ext: string): string {
35
+ const dotExt = ext.startsWith('.') ? ext : `.${ext}`;
36
+ if (OVERRIDES[dotExt.toLowerCase()]) return OVERRIDES[dotExt.toLowerCase()];
37
+ // mime-types expects extension without the dot
38
+ return mime.contentType(dotExt.slice(1)) || 'application/octet-stream';
39
+ }
40
+
41
+ /**
42
+ * Standard content-type map for diagram export formats.
43
+ */
44
+ export const DIAGRAM_CONTENT_TYPES: Record<string, string> = {
45
+ svg: 'image/svg+xml',
46
+ png: 'image/png',
47
+ pdf: 'application/pdf',
48
+ eps: 'application/postscript',
49
+ txt: 'text/plain; charset=utf-8',
50
+ latex: 'application/x-latex',
51
+ };
52
+
53
+ /**
54
+ * Map file extensions to highlight.js language identifiers.
55
+ */
56
+ /**
57
+ * Check if a content type should be displayed inline
58
+ */
59
+ export function isInlineType(contentType: string): boolean {
60
+ const inlineTypes = [
61
+ 'image/',
62
+ 'video/',
63
+ 'audio/',
64
+ 'text/',
65
+ 'application/pdf',
66
+ 'application/json',
67
+ 'application/xml',
68
+ ];
69
+ return inlineTypes.some((type) => contentType.startsWith(type));
70
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tests for formatting utilities
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { formatRelativeTime, formatSize, nowIso } from './formatters.js';
8
+
9
+ describe('formatters', () => {
10
+ describe('formatSize', () => {
11
+ it('should format 0 bytes', () => {
12
+ expect(formatSize(0)).toBe('0 B');
13
+ });
14
+
15
+ it('should format bytes', () => {
16
+ expect(formatSize(500)).toBe('500 B');
17
+ expect(formatSize(1023)).toBe('1023 B');
18
+ });
19
+
20
+ it('should format kilobytes', () => {
21
+ expect(formatSize(1024)).toBe('1.0 KB');
22
+ expect(formatSize(1536)).toBe('1.5 KB');
23
+ expect(formatSize(10240)).toBe('10.0 KB');
24
+ });
25
+
26
+ it('should format megabytes', () => {
27
+ expect(formatSize(1048576)).toBe('1.0 MB');
28
+ expect(formatSize(1572864)).toBe('1.5 MB');
29
+ });
30
+
31
+ it('should format gigabytes', () => {
32
+ expect(formatSize(1073741824)).toBe('1.0 GB');
33
+ expect(formatSize(2147483648)).toBe('2.0 GB');
34
+ });
35
+
36
+ it('should format terabytes', () => {
37
+ expect(formatSize(1099511627776)).toBe('1.0 TB');
38
+ });
39
+ });
40
+
41
+ describe('formatRelativeTime', () => {
42
+ beforeEach(() => {
43
+ vi.useFakeTimers();
44
+ vi.setSystemTime(new Date('2026-02-15T12:00:00Z'));
45
+ });
46
+
47
+ it('should return null for null input', () => {
48
+ expect(formatRelativeTime(null)).toBeNull();
49
+ });
50
+
51
+ it('should return null for future timestamps', () => {
52
+ const future = new Date('2026-02-15T13:00:00Z').toISOString();
53
+ expect(formatRelativeTime(future)).toBeNull();
54
+ });
55
+
56
+ it('should return "just now" for very recent timestamps', () => {
57
+ const recent = new Date('2026-02-15T11:59:30Z').toISOString();
58
+ expect(formatRelativeTime(recent)).toBe('just now');
59
+ });
60
+
61
+ it('should return minutes ago', () => {
62
+ const mins5 = new Date('2026-02-15T11:55:00Z').toISOString();
63
+ expect(formatRelativeTime(mins5)).toBe('5m ago');
64
+
65
+ const mins45 = new Date('2026-02-15T11:15:00Z').toISOString();
66
+ expect(formatRelativeTime(mins45)).toBe('45m ago');
67
+ });
68
+
69
+ it('should return hours ago', () => {
70
+ const hours2 = new Date('2026-02-15T10:00:00Z').toISOString();
71
+ expect(formatRelativeTime(hours2)).toBe('2h ago');
72
+
73
+ const hours12 = new Date('2026-02-15T00:00:00Z').toISOString();
74
+ expect(formatRelativeTime(hours12)).toBe('12h ago');
75
+ });
76
+
77
+ it('should return days ago', () => {
78
+ const days1 = new Date('2026-02-14T12:00:00Z').toISOString();
79
+ expect(formatRelativeTime(days1)).toBe('1d ago');
80
+
81
+ const days7 = new Date('2026-02-08T12:00:00Z').toISOString();
82
+ expect(formatRelativeTime(days7)).toBe('7d ago');
83
+ });
84
+
85
+ it('should prioritize days over hours', () => {
86
+ const days1Hours5 = new Date('2026-02-14T07:00:00Z').toISOString();
87
+ expect(formatRelativeTime(days1Hours5)).toBe('1d ago');
88
+ });
89
+ });
90
+
91
+ describe('nowIso', () => {
92
+ beforeEach(() => {
93
+ vi.useFakeTimers();
94
+ vi.setSystemTime(new Date('2026-02-15T12:34:56.789Z'));
95
+ });
96
+
97
+ it('should return current time in ISO format', () => {
98
+ expect(nowIso()).toBe('2026-02-15T12:34:56.789Z');
99
+ });
100
+
101
+ it('should match ISO 8601 format', () => {
102
+ expect(nowIso()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Formatting utilities for dates, file sizes, etc.
3
+ */
4
+
5
+ /**
6
+ * Format file size in human-readable format
7
+ */
8
+ export function formatSize(bytes: number): string {
9
+ if (bytes === 0) return '0 B';
10
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
11
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
12
+ const value = bytes / Math.pow(1024, i);
13
+ const decimals = i > 0 ? 1 : 0;
14
+ return `${value.toFixed(decimals)} ${units[i]}`;
15
+ }
16
+
17
+ /**
18
+ * Format ISO timestamp as relative time (e.g., "2h ago")
19
+ */
20
+ export function formatRelativeTime(isoTimestamp: string | null): string | null {
21
+ if (!isoTimestamp) return null;
22
+
23
+ const then = new Date(isoTimestamp).getTime();
24
+ const now = Date.now();
25
+ const diffMs = now - then;
26
+
27
+ if (diffMs < 0) return null;
28
+
29
+ const mins = Math.floor(diffMs / 60000);
30
+ const hours = Math.floor(mins / 60);
31
+ const days = Math.floor(hours / 24);
32
+
33
+ if (days > 0) return `${String(days)}d ago`;
34
+ if (hours > 0) return `${String(hours)}h ago`;
35
+ if (mins > 0) return `${String(mins)}m ago`;
36
+ return 'just now';
37
+ }
38
+
39
+ /**
40
+ * Get current ISO timestamp
41
+ */
42
+ export function nowIso(): string {
43
+ return new Date().toISOString();
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resolve the service package version by walking up from the caller's directory.
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function findPackageJson(startDir: string): string {
10
+ let dir = startDir;
11
+ while (dir !== path.dirname(dir)) {
12
+ const candidate = path.join(dir, 'package.json');
13
+ if (fs.existsSync(candidate)) {
14
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8')) as {
15
+ name?: string;
16
+ };
17
+ // Find our package specifically, not the monorepo root
18
+ if (pkg.name === '@karmaniverous/jeeves-server') return candidate;
19
+ }
20
+ dir = path.dirname(dir);
21
+ }
22
+ throw new Error('Could not find @karmaniverous/jeeves-server package.json');
23
+ }
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const pkgPath = findPackageJson(__dirname);
27
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
28
+
29
+ /** The package version of the jeeves-server service package. */
30
+ export const packageVersion: string = pkg.version;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Platform abstraction for filesystem operations.
3
+ *
4
+ * Handles the differences between Windows (drive letters, backslashes)
5
+ * and Linux (mount points, forward slashes) for URL path ↔ filesystem path conversion.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const IS_WINDOWS = process.platform === 'win32';
12
+
13
+ interface RootEntry {
14
+ /** URL-safe identifier (drive letter lowercase on Windows, mount name on Linux) */
15
+ id: string;
16
+ /** Display label */
17
+ label: string;
18
+ /** Filesystem path */
19
+ fsPath: string;
20
+ }
21
+
22
+ /**
23
+ * Discover available filesystem roots.
24
+ * Windows: enumerate accessible drive letters.
25
+ * Linux: return configured roots or default to '/'.
26
+ */
27
+ export function getRoots(
28
+ configuredRoots?: Record<string, string>,
29
+ ): RootEntry[] {
30
+ if (IS_WINDOWS) {
31
+ const roots: RootEntry[] = [];
32
+ for (let code = 65; code <= 90; code++) {
33
+ const letter = String.fromCharCode(code);
34
+ const drivePath = `${letter}:\\`;
35
+ try {
36
+ fs.accessSync(drivePath, fs.constants.R_OK);
37
+ roots.push({
38
+ id: letter.toLowerCase(),
39
+ label: `${letter}:`,
40
+ fsPath: drivePath,
41
+ });
42
+ } catch {
43
+ // Drive not accessible
44
+ }
45
+ }
46
+ return roots;
47
+ }
48
+
49
+ // Linux: use configured roots or default to filesystem root
50
+ if (configuredRoots && Object.keys(configuredRoots).length > 0) {
51
+ return Object.entries(configuredRoots).map(([id, fsPath]) => ({
52
+ id,
53
+ label: fsPath,
54
+ fsPath: fsPath.endsWith('/') ? fsPath : fsPath + '/',
55
+ }));
56
+ }
57
+
58
+ return [{ id: 'root', label: '/', fsPath: '/' }];
59
+ }
60
+
61
+ /**
62
+ * Convert a URL path to a filesystem path.
63
+ *
64
+ * URL paths use forward slashes and start with the root id:
65
+ * Windows: /e/jeeves-server/README.md → E:\\jeeves-server\\README.md
66
+ * Linux: /home/user/docs/README.md → /home/user/docs/README.md
67
+ * /root/docs/README.md → /docs/README.md (if root id is "root" mapping to "/")
68
+ */
69
+ export function urlPathToFs(
70
+ urlPath: string,
71
+ roots: RootEntry[],
72
+ ): string | null {
73
+ const normalized = urlPath.replace(/^\/+/, '');
74
+ if (!normalized) return null;
75
+
76
+ if (IS_WINDOWS) {
77
+ // First segment is the drive letter
78
+ const slashIdx = normalized.indexOf('/');
79
+ const driveLetter =
80
+ slashIdx >= 0 ? normalized.substring(0, slashIdx) : normalized;
81
+ const rest = slashIdx >= 0 ? normalized.substring(slashIdx + 1) : '';
82
+
83
+ if (driveLetter.length !== 1) return null;
84
+ const fsPath = `${driveLetter.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
85
+ return fsPath;
86
+ }
87
+
88
+ // Linux: match against configured roots
89
+ for (const root of roots) {
90
+ if (normalized.startsWith(root.id + '/') || normalized === root.id) {
91
+ const rest = normalized.substring(root.id.length);
92
+ // rest starts with '/' or is empty
93
+ const fsPath = root.fsPath.replace(/\/+$/, '') + (rest || '/');
94
+ return fsPath;
95
+ }
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Convert a filesystem path to a URL path.
103
+ *
104
+ * Windows: E:\\jeeves-server\\README.md → /e/jeeves-server/README.md
105
+ * Linux: /home/user/docs/README.md → /home/user/docs/README.md (root="root" → "/")
106
+ */
107
+ export function fsPathToUrl(fsPath: string, roots: RootEntry[]): string {
108
+ if (IS_WINDOWS) {
109
+ return (
110
+ '/' +
111
+ fsPath
112
+ .replace(/\\/g, '/')
113
+ .replace(/^([A-Za-z]):/, (_, d: string) => d.toLowerCase())
114
+ );
115
+ }
116
+
117
+ // Linux: find the matching root and prepend the root id
118
+ for (const root of roots) {
119
+ const rootFs = root.fsPath.replace(/\/+$/, '');
120
+ if (fsPath === rootFs || fsPath.startsWith(rootFs + '/')) {
121
+ const rest = fsPath.substring(rootFs.length); // starts with '/' or is empty
122
+ if (root.id === 'root' && root.fsPath === '/') {
123
+ // Default root — URL path is just the fs path
124
+ return rest || '/';
125
+ }
126
+ return '/' + root.id + rest;
127
+ }
128
+ }
129
+
130
+ // Fallback: return as-is with forward slashes
131
+ return fsPath.replace(/\\/g, '/');
132
+ }
133
+
134
+ /**
135
+ * Split a filesystem path into breadcrumb parts.
136
+ * Returns [\{label, urlPath\}] from root to leaf.
137
+ */
138
+ export function breadcrumbParts(
139
+ fsPath: string,
140
+ roots: RootEntry[],
141
+ ): { label: string; path: string }[] {
142
+ const urlPath = fsPathToUrl(fsPath, roots);
143
+ const parts = urlPath
144
+ .replace(/^\/+/, '')
145
+ .split('/')
146
+ .filter((p) => p);
147
+
148
+ return parts.map((_part, i) => {
149
+ const accumulated = parts.slice(0, i + 1).join('/');
150
+ return { label: parts[i], path: accumulated };
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Recursively calculate total size of a directory in bytes.
156
+ */
157
+ export function getDirSize(dirPath: string): number {
158
+ let totalSize = 0;
159
+ try {
160
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
161
+ for (const entry of entries) {
162
+ const entryPath = path.join(dirPath, entry.name);
163
+ try {
164
+ if (entry.isDirectory()) {
165
+ totalSize += getDirSize(entryPath);
166
+ } else {
167
+ const s = fs.statSync(entryPath);
168
+ totalSize += s.size;
169
+ }
170
+ } catch {
171
+ /* skip inaccessible */
172
+ }
173
+ }
174
+ } catch {
175
+ /* skip inaccessible */
176
+ }
177
+ return totalSize;
178
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Server state management (key rotation tracking, etc.)
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+
7
+ import { getConfig } from '../config/index.js';
8
+ import type { ServerState } from '../config/types.js';
9
+
10
+ /**
11
+ * Load state from file
12
+ */
13
+ export function loadState(): ServerState {
14
+ const { stateFile } = getConfig();
15
+ try {
16
+ if (fs.existsSync(stateFile)) {
17
+ const content = fs.readFileSync(stateFile, 'utf8');
18
+ return JSON.parse(content) as ServerState;
19
+ }
20
+ } catch {
21
+ // Ignore errors, return empty state
22
+ }
23
+ return {};
24
+ }
25
+
26
+ /**
27
+ * Save state to file
28
+ */
29
+ export function saveState(state: ServerState): void {
30
+ const { stateFile } = getConfig();
31
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
32
+ }
33
+
34
+ /**
35
+ * Set key rotation timestamp
36
+ */
37
+ export function setKeyRotationTimestamp(timestamp: string): void {
38
+ const state = loadState();
39
+ state.keyRotatedAt = timestamp;
40
+ saveState(state);
41
+ }
42
+
43
+ /**
44
+ * Set an insider's auto-generated key in state
45
+ */
46
+ export function setInsiderKey(
47
+ email: string,
48
+ seed: string,
49
+ createdAt: string,
50
+ ): void {
51
+ const state = loadState();
52
+ if (!state.insiderKeys) state.insiderKeys = {};
53
+ state.insiderKeys[email.toLowerCase()] = { seed, createdAt };
54
+ saveState(state);
55
+ }
@@ -0,0 +1,18 @@
1
+ # Diagram Retry Test
2
+
3
+ ## Valid Diagram
4
+
5
+ ```mermaid
6
+ graph LR
7
+ A[Start] --> B[Process]
8
+ B --> C[End]
9
+ ```
10
+
11
+ ## Invalid Diagram (should fail)
12
+
13
+ ```mermaid
14
+ graph LR
15
+ A[Start] --> B[Process]
16
+ B --> C[Success!]
17
+ C --> D[End]
18
+ ```