@karmaniverous/jeeves-server 3.4.2 → 3.5.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 (127) hide show
  1. package/.tsbuildinfo +1 -1
  2. package/CHANGELOG.md +38 -1
  3. package/README.md +18 -17
  4. package/client/package.json +19 -19
  5. package/client/src/components/SearchModal.tsx +11 -1
  6. package/client/src/components/layout/Header.tsx +3 -3
  7. package/client/src/lib/api.ts +10 -5
  8. package/dist/client/assets/CodeEditor-Brh86AGF.js +1 -0
  9. package/dist/client/assets/CodeViewer-Cegj3cEn.js +1 -0
  10. package/dist/client/assets/dist-2YqVIvgv.js +2 -0
  11. package/dist/client/assets/dist-5vamY028.js +1 -0
  12. package/dist/client/assets/dist-6_auAGci.js +1 -0
  13. package/dist/client/assets/dist-B0kq1DQG.js +1 -0
  14. package/dist/client/assets/dist-B2SZD_eN.js +1 -0
  15. package/dist/client/assets/dist-B2t4dYA2.js +1 -0
  16. package/dist/client/assets/dist-B5gFYAn7.js +1 -0
  17. package/dist/client/assets/dist-BPy6CnYN.js +1 -0
  18. package/dist/client/assets/dist-CL6VCrQn.js +9 -0
  19. package/dist/client/assets/dist-CWsHar9N.js +1 -0
  20. package/dist/client/assets/dist-CnFc5Ssx.js +1 -0
  21. package/dist/client/assets/dist-DSgLBuTS.js +1 -0
  22. package/dist/client/assets/dist-DUcac0X_.js +7 -0
  23. package/dist/client/assets/dist-DcTcc-BG.js +6 -0
  24. package/dist/client/assets/dist-DvfTyWk_.js +1 -0
  25. package/dist/client/assets/dist-Dz1Ulpqa.js +1 -0
  26. package/dist/client/assets/dist-Kr-mUYW1.js +5 -0
  27. package/dist/client/assets/dist-OX4k3MMG.js +2 -0
  28. package/dist/client/assets/dist-qiU0qoeK.js +1 -0
  29. package/dist/client/assets/dist-ui4J6fvl.js +23 -0
  30. package/dist/client/assets/index-Dk_myGs4.css +2 -0
  31. package/dist/client/assets/index-DrBXupPz.js +62 -0
  32. package/dist/client/assets/theme-CPpIxvB0.js +2 -0
  33. package/dist/client/index.html +3 -2
  34. package/dist/src/cli/commands/config.test.js +5 -40
  35. package/dist/src/cli/index.js +9 -15
  36. package/dist/src/cli/start-server.js +16 -0
  37. package/dist/src/config/index.js +48 -37
  38. package/dist/src/config/loadConfig.test.js +27 -25
  39. package/dist/src/config/migration.js +60 -0
  40. package/dist/src/config/schema.js +4 -3
  41. package/dist/src/descriptor.js +46 -0
  42. package/dist/src/routes/api/diagramExport.js +101 -0
  43. package/dist/src/routes/api/diagramExport.test.js +134 -0
  44. package/dist/src/routes/api/events.js +13 -0
  45. package/dist/src/routes/api/export.js +6 -82
  46. package/dist/src/routes/api/index.js +4 -0
  47. package/dist/src/routes/api/search.js +9 -50
  48. package/dist/src/routes/api/sharing.js +40 -23
  49. package/dist/src/routes/api/sharing.test.js +52 -0
  50. package/dist/src/routes/auth.js +1 -1
  51. package/dist/src/routes/config.js +8 -2
  52. package/dist/src/routes/keys.js +4 -4
  53. package/dist/src/routes/path/index.js +1 -1
  54. package/dist/src/routes/status.js +15 -16
  55. package/dist/src/routes/status.test.js +13 -8
  56. package/dist/src/server.js +21 -16
  57. package/dist/src/services/markdown.js +2 -1
  58. package/dist/src/services/markdown.test.js +22 -0
  59. package/dist/src/util/packageVersion.js +7 -16
  60. package/dist/src/util/packageVersion.test.js +7 -0
  61. package/guides/api-integration.md +4 -0
  62. package/guides/deployment.md +11 -10
  63. package/guides/event-gateway.md +4 -0
  64. package/guides/exports.md +4 -0
  65. package/guides/index.md +1 -1
  66. package/guides/setup.md +17 -16
  67. package/guides/sharing.md +4 -0
  68. package/package.json +3 -3
  69. package/scripts/download-plantuml.js +0 -1
  70. package/src/cli/commands/config.test.ts +5 -45
  71. package/src/cli/index.ts +9 -16
  72. package/src/cli/start-server.ts +21 -0
  73. package/src/config/index.ts +56 -43
  74. package/src/config/loadConfig.test.ts +27 -29
  75. package/src/config/migration.ts +76 -0
  76. package/src/config/schema.ts +5 -4
  77. package/src/descriptor.ts +55 -0
  78. package/src/routes/api/diagramExport.test.ts +200 -0
  79. package/src/routes/api/diagramExport.ts +170 -0
  80. package/src/routes/api/events.ts +22 -0
  81. package/src/routes/api/export.ts +6 -131
  82. package/src/routes/api/index.ts +4 -0
  83. package/src/routes/api/search.ts +9 -63
  84. package/src/routes/api/sharing.test.ts +66 -0
  85. package/src/routes/api/sharing.ts +47 -23
  86. package/src/routes/auth.ts +1 -1
  87. package/src/routes/config.ts +15 -2
  88. package/src/routes/keys.ts +4 -4
  89. package/src/routes/path/index.ts +1 -1
  90. package/src/routes/status.test.ts +14 -8
  91. package/src/routes/status.ts +56 -62
  92. package/src/server.ts +29 -17
  93. package/src/services/markdown.test.ts +26 -0
  94. package/src/services/markdown.ts +2 -1
  95. package/src/util/packageVersion.test.ts +9 -0
  96. package/src/util/packageVersion.ts +11 -18
  97. package/src/util/platform.ts +1 -1
  98. package/dist/client/assets/CodeEditor-DQZZL5Rq.js +0 -1
  99. package/dist/client/assets/CodeViewer-ofJVD1Vn.js +0 -1
  100. package/dist/client/assets/index--MBieNJA.js +0 -1
  101. package/dist/client/assets/index-BENeXQI_.js +0 -1
  102. package/dist/client/assets/index-BbBpoOxz.js +0 -1
  103. package/dist/client/assets/index-BdV9g5AM.js +0 -6
  104. package/dist/client/assets/index-BjAilRri.js +0 -2
  105. package/dist/client/assets/index-BqbhWo2I.js +0 -3
  106. package/dist/client/assets/index-CVbycZ0H.js +0 -1
  107. package/dist/client/assets/index-Cs5oz2oJ.js +0 -5
  108. package/dist/client/assets/index-D-RC7ZS6.css +0 -1
  109. package/dist/client/assets/index-D8KZVveX.js +0 -1
  110. package/dist/client/assets/index-DC4HMHxY.js +0 -13
  111. package/dist/client/assets/index-DcY2RXqX.js +0 -1
  112. package/dist/client/assets/index-Duy-tZYV.js +0 -1
  113. package/dist/client/assets/index-Dw7rDFmE.js +0 -7
  114. package/dist/client/assets/index-FlCUvrjv.js +0 -2
  115. package/dist/client/assets/index-K6OVmfhg.js +0 -1
  116. package/dist/client/assets/index-MLwyFRN0.js +0 -1
  117. package/dist/client/assets/index-OpqBpSjn.js +0 -1
  118. package/dist/client/assets/index-SsHei0HE.js +0 -1
  119. package/dist/client/assets/index-jSGuHSeS.js +0 -62
  120. package/dist/client/assets/index-uQa2yckk.js +0 -1
  121. package/dist/client/assets/index-udkXoIER.js +0 -1
  122. package/dist/src/cli/commands/config.js +0 -105
  123. package/dist/src/cli/commands/service.js +0 -93
  124. package/dist/src/cli/commands/start.js +0 -24
  125. package/src/cli/commands/config.ts +0 -117
  126. package/src/cli/commands/service.ts +0 -129
  127. package/src/cli/commands/start.ts +0 -27
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Diagram export routes — standalone Mermaid and PlantUML file rendering.
3
+ *
4
+ * Extracted from export.ts to maintain single responsibility and stay within
5
+ * the 300 LOC limit. Both handlers share a common cache-then-render pattern
6
+ * via `serveDiagramExport()`.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { getConfig } from '../../config/index.js';
13
+ import { cacheDiagramBuffer, getCachedDiagramBuffer, } from '../../services/diagramCache.js';
14
+ import { renderMermaidToFile } from '../../services/mermaid.js';
15
+ import { getPlantUmlFormats, renderPlantUmlToBuffer, } from '../../services/plantuml.js';
16
+ import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
17
+ import { getRoots, urlPathToFs } from '../../util/platform.js';
18
+ /** Resolve a URL path to a validated filesystem path, or send an error reply. */
19
+ function resolveExportPath(reqPath, roots, reply) {
20
+ if (!reqPath) {
21
+ void reply.code(400).send({ error: 'Path required' });
22
+ return null;
23
+ }
24
+ const fsPath = urlPathToFs(reqPath, roots);
25
+ if (!fsPath) {
26
+ void reply.code(404).send({ error: 'Invalid path' });
27
+ return null;
28
+ }
29
+ return path.resolve(fsPath);
30
+ }
31
+ /** Send a diagram buffer with appropriate content-type and disposition. */
32
+ function sendDiagram(reply, buffer, format, downloadName) {
33
+ void reply
34
+ .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
35
+ .header('Content-Disposition', `attachment; filename="${downloadName}"`)
36
+ .send(buffer);
37
+ }
38
+ /**
39
+ * Serve a diagram export with cache-first semantics.
40
+ *
41
+ * Checks the diagram cache, renders on miss, caches the result, and sends
42
+ * the response. This eliminates the duplicated cache-then-render pattern
43
+ * between the Mermaid and PlantUML handlers.
44
+ */
45
+ async function serveDiagramExport(reply, engine, resolved, ext, format, render) {
46
+ const source = fs.readFileSync(resolved, 'utf8');
47
+ const baseName = path.basename(resolved, ext);
48
+ const downloadName = `${baseName}.${format}`;
49
+ const cached = getCachedDiagramBuffer(engine, source, format);
50
+ if (cached) {
51
+ sendDiagram(reply, cached, format, downloadName);
52
+ return;
53
+ }
54
+ const buffer = await render(resolved, format);
55
+ if (!buffer) {
56
+ void reply.code(500).send({
57
+ error: `${engine.charAt(0).toUpperCase() + engine.slice(1)} render failed`,
58
+ });
59
+ return;
60
+ }
61
+ cacheDiagramBuffer(engine, source, buffer, format);
62
+ sendDiagram(reply, buffer, format, downloadName);
63
+ }
64
+ // eslint-disable-next-line @typescript-eslint/require-await
65
+ export const diagramExportRoutes = async (fastify) => {
66
+ const roots = getRoots(getConfig().roots);
67
+ // GET /api/mermaid-export/*
68
+ fastify.get('/api/mermaid-export/*', async (request, reply) => {
69
+ const resolved = resolveExportPath(request.params['*'], roots, reply);
70
+ if (!resolved)
71
+ return;
72
+ if (!fs.existsSync(resolved) ||
73
+ !resolved.toLowerCase().endsWith('.mmd')) {
74
+ return reply.code(404).send({ error: 'Mermaid file not found' });
75
+ }
76
+ const mermaidFormats = ['svg', 'png', 'pdf'];
77
+ const format = mermaidFormats.includes(request.query.format ?? '')
78
+ ? request.query.format
79
+ : 'svg';
80
+ await serveDiagramExport(reply, 'mermaid', resolved, '.mmd', format, async (filePath, fmt) => {
81
+ const outFile = await renderMermaidToFile(filePath, fmt);
82
+ return outFile ? fs.readFileSync(outFile) : null;
83
+ });
84
+ });
85
+ // GET /api/plantuml-export/*
86
+ fastify.get('/api/plantuml-export/*', async (request, reply) => {
87
+ const resolved = resolveExportPath(request.params['*'], roots, reply);
88
+ if (!resolved)
89
+ return;
90
+ const ext = path.extname(resolved).toLowerCase();
91
+ if (!fs.existsSync(resolved) ||
92
+ !['.puml', '.plantuml', '.pu'].includes(ext)) {
93
+ return reply.code(404).send({ error: 'PlantUML file not found' });
94
+ }
95
+ const supported = getPlantUmlFormats();
96
+ const format = supported.includes(request.query.format ?? '')
97
+ ? request.query.format
98
+ : 'svg';
99
+ await serveDiagramExport(reply, 'plantuml', resolved, ext, format, renderPlantUmlToBuffer);
100
+ });
101
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tests for diagram export route handlers.
3
+ *
4
+ * Validates the cache-first rendering pipeline shared by Mermaid and PlantUML
5
+ * handlers, and the path resolution + error handling for both routes.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import fs from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import path from 'node:path';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+ // Mock config before importing the module under test
14
+ const tmpDir = path.join(tmpdir(), `diagramExport-test-${String(Date.now())}`);
15
+ vi.mock('../../config/index.js', () => ({
16
+ getConfig: () => ({
17
+ roots: {},
18
+ }),
19
+ }));
20
+ vi.mock('../../services/diagramCache.js', () => {
21
+ let cache = {};
22
+ return {
23
+ getCachedDiagramBuffer: (engine, source, format) => {
24
+ const key = `${engine}:${source}:${format}`;
25
+ return cache[key] ?? null;
26
+ },
27
+ cacheDiagramBuffer: (engine, source, buffer, format) => {
28
+ cache[`${engine}:${source}:${format}`] = buffer;
29
+ },
30
+ _reset: () => {
31
+ cache = {};
32
+ },
33
+ };
34
+ });
35
+ vi.mock('../../services/mermaid.js', () => ({
36
+ renderMermaidToFile: vi.fn(),
37
+ }));
38
+ vi.mock('../../services/plantuml.js', () => ({
39
+ getPlantUmlFormats: () => ['svg', 'png'],
40
+ renderPlantUmlToBuffer: vi.fn(),
41
+ }));
42
+ const { diagramExportRoutes } = await import('./diagramExport.js');
43
+ const { renderMermaidToFile } = await import('../../services/mermaid.js');
44
+ const { renderPlantUmlToBuffer } = await import('../../services/plantuml.js');
45
+ /** Minimal Fastify reply mock that captures sent data. */
46
+ function createReplyMock() {
47
+ const headers = {};
48
+ let sentData = null;
49
+ let statusCode = 200;
50
+ const reply = {
51
+ code: (c) => {
52
+ statusCode = c;
53
+ return reply;
54
+ },
55
+ header: (key, value) => {
56
+ headers[key.toLowerCase()] = value;
57
+ return reply;
58
+ },
59
+ send: (data) => {
60
+ sentData = data;
61
+ return reply;
62
+ },
63
+ get statusCode() {
64
+ return statusCode;
65
+ },
66
+ get headers() {
67
+ return headers;
68
+ },
69
+ get sentData() {
70
+ return sentData;
71
+ },
72
+ };
73
+ return reply;
74
+ }
75
+ describe('diagramExportRoutes', () => {
76
+ const routes = {};
77
+ beforeEach(async () => {
78
+ fs.mkdirSync(tmpDir, { recursive: true });
79
+ const fakeFastify = {
80
+ get: (routePath, handler) => {
81
+ routes[routePath] = handler;
82
+ },
83
+ };
84
+ await diagramExportRoutes(fakeFastify, {});
85
+ });
86
+ afterEach(() => {
87
+ fs.rmSync(tmpDir, { recursive: true, force: true });
88
+ vi.restoreAllMocks();
89
+ });
90
+ it('returns 400 when no path is provided for mermaid export', async () => {
91
+ const reply = createReplyMock();
92
+ await routes['/api/mermaid-export/*']({ params: { '*': '' }, query: {} }, reply);
93
+ expect(reply.statusCode).toBe(400);
94
+ });
95
+ it('returns 404 for non-existent mermaid file', async () => {
96
+ const reply = createReplyMock();
97
+ await routes['/api/mermaid-export/*']({ params: { '*': 'nonexistent.mmd' }, query: {} }, reply);
98
+ expect(reply.statusCode).toBe(404);
99
+ });
100
+ it('returns 400 when no path is provided for plantuml export', async () => {
101
+ const reply = createReplyMock();
102
+ await routes['/api/plantuml-export/*']({ params: { '*': '' }, query: {} }, reply);
103
+ expect(reply.statusCode).toBe(400);
104
+ });
105
+ it('returns 404 for non-existent plantuml file', async () => {
106
+ const reply = createReplyMock();
107
+ await routes['/api/plantuml-export/*']({ params: { '*': 'nonexistent.puml' }, query: {} }, reply);
108
+ expect(reply.statusCode).toBe(404);
109
+ });
110
+ it('sends 500 when mermaid render returns null', async () => {
111
+ // Create a real .mmd file
112
+ const mmdPath = path.join(tmpDir, 'test.mmd');
113
+ fs.writeFileSync(mmdPath, 'graph TD; A-->B');
114
+ // Mock urlPathToFs to resolve our temp path
115
+ const platformMod = await import('../../util/platform.js');
116
+ vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(mmdPath);
117
+ vi.mocked(renderMermaidToFile).mockResolvedValue(null);
118
+ const reply = createReplyMock();
119
+ await routes['/api/mermaid-export/*']({ params: { '*': 'test.mmd' }, query: { format: 'svg' } }, reply);
120
+ expect(reply.statusCode).toBe(500);
121
+ expect(reply.sentData.error).toContain('render failed');
122
+ });
123
+ it('sends 500 when plantuml render returns null', async () => {
124
+ const pumlPath = path.join(tmpDir, 'test.puml');
125
+ fs.writeFileSync(pumlPath, '@startuml\nA -> B\n@enduml');
126
+ const platformMod = await import('../../util/platform.js');
127
+ vi.spyOn(platformMod, 'urlPathToFs').mockReturnValue(pumlPath);
128
+ vi.mocked(renderPlantUmlToBuffer).mockResolvedValue(null);
129
+ const reply = createReplyMock();
130
+ await routes['/api/plantuml-export/*']({ params: { '*': 'test.puml' }, query: { format: 'svg' } }, reply);
131
+ expect(reply.statusCode).toBe(500);
132
+ expect(reply.sentData.error).toContain('render failed');
133
+ });
134
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * GET /api/events — returns recent event log entries.
3
+ */
4
+ import { getRecentEvents } from '../../services/eventLog.js';
5
+ // eslint-disable-next-line @typescript-eslint/require-await
6
+ export const eventsRoutes = async (fastify) => {
7
+ fastify.get('/events',
8
+ // eslint-disable-next-line @typescript-eslint/require-await
9
+ async (request) => {
10
+ const limit = Math.min(Math.max(parseInt(request.query.limit ?? '20', 10) || 20, 1), 100);
11
+ return getRecentEvents(limit);
12
+ });
13
+ };
@@ -1,19 +1,18 @@
1
1
  /**
2
- * Export API routes.
2
+ * Document and directory export routes.
3
3
  *
4
- * Handles: /api/export/*, /api/mermaid-export/*, /api/plantuml-export/*
4
+ * Handles: /api/export/* (PDF/DOCX/ZIP), /api/export-cache/* (cache clearing).
5
+ * Diagram export routes (Mermaid/PlantUML) are in diagramExport.ts.
6
+ *
7
+ * @packageDocumentation
5
8
  */
6
9
  import fs from 'node:fs';
7
10
  import path from 'node:path';
8
11
  import archiver from 'archiver';
9
12
  import { getConfig } from '../../config/index.js';
10
- import { cacheDiagramBuffer, getCachedDiagramBuffer, } from '../../services/diagramCache.js';
11
13
  import { appendEvent } from '../../services/eventQueue.js';
12
14
  import { exportPage } from '../../services/export.js';
13
15
  import { cacheExport, clearDiagramCacheForFile, clearExportCache, clearStandaloneDiagramCache, getCachedExport, } from '../../services/exportCache.js';
14
- import { renderMermaidToFile } from '../../services/mermaid.js';
15
- import { getPlantUmlFormats, renderPlantUmlToBuffer, } from '../../services/plantuml.js';
16
- import { DIAGRAM_CONTENT_TYPES } from '../../util/fileDetection.js';
17
16
  import { getDirSize, getRoots, urlPathToFs } from '../../util/platform.js';
18
17
  // eslint-disable-next-line @typescript-eslint/require-await
19
18
  export const exportRoutes = async (fastify) => {
@@ -78,7 +77,7 @@ export const exportRoutes = async (fastify) => {
78
77
  return reply
79
78
  .code(500)
80
79
  .send({ error: 'Export unavailable — no internal key configured' });
81
- const exportUrl = `http://localhost:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
80
+ const exportUrl = `http://127.0.0.1:${String(port)}/browse/${reqPath}?key=${exportKey}&render_diagrams=1&plain_code=1`;
82
81
  const fileName = path.basename(resolved);
83
82
  const baseName = fileName.replace(/\.md$/i, '');
84
83
  try {
@@ -140,79 +139,4 @@ export const exportRoutes = async (fastify) => {
140
139
  },
141
140
  });
142
141
  });
143
- // GET /api/mermaid-export/*
144
- fastify.get('/api/mermaid-export/*', async (request, reply) => {
145
- const reqPath = request.params['*'];
146
- if (!reqPath)
147
- return reply.code(400).send({ error: 'Path required' });
148
- const mmdFsPath = urlPathToFs(reqPath, roots);
149
- if (!mmdFsPath)
150
- return reply.code(404).send({ error: 'Invalid path' });
151
- const resolved = path.resolve(mmdFsPath);
152
- if (!fs.existsSync(resolved) ||
153
- !resolved.toLowerCase().endsWith('.mmd')) {
154
- return reply.code(404).send({ error: 'Mermaid file not found' });
155
- }
156
- const mermaidFormats = ['svg', 'png', 'pdf'];
157
- const format = mermaidFormats.includes(request.query.format ?? '')
158
- ? request.query.format
159
- : 'svg';
160
- const source = fs.readFileSync(resolved, 'utf8');
161
- // Check cache
162
- const cachedBuffer = getCachedDiagramBuffer('mermaid', source, format);
163
- if (cachedBuffer) {
164
- const downloadName = `${path.basename(resolved, '.mmd')}.${format}`;
165
- return reply
166
- .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
167
- .header('Content-Disposition', `attachment; filename="${downloadName}"`)
168
- .send(cachedBuffer);
169
- }
170
- const outFile = await renderMermaidToFile(resolved, format);
171
- if (!outFile)
172
- return reply.code(500).send({ error: 'Mermaid render failed' });
173
- const content = fs.readFileSync(outFile);
174
- cacheDiagramBuffer('mermaid', source, content, format);
175
- const downloadName = path.basename(outFile);
176
- return reply
177
- .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
178
- .header('Content-Disposition', `attachment; filename="${downloadName}"`)
179
- .send(content);
180
- });
181
- // GET /api/plantuml-export/*
182
- fastify.get('/api/plantuml-export/*', async (request, reply) => {
183
- const reqPath = request.params['*'];
184
- if (!reqPath)
185
- return reply.code(400).send({ error: 'Path required' });
186
- const pumlFsPath = urlPathToFs(reqPath, roots);
187
- if (!pumlFsPath)
188
- return reply.code(404).send({ error: 'Invalid path' });
189
- const resolved = path.resolve(pumlFsPath);
190
- const ext = path.extname(resolved).toLowerCase();
191
- if (!fs.existsSync(resolved) ||
192
- !['.puml', '.plantuml', '.pu'].includes(ext)) {
193
- return reply.code(404).send({ error: 'PlantUML file not found' });
194
- }
195
- const supported = getPlantUmlFormats();
196
- const format = supported.includes(request.query.format ?? '')
197
- ? request.query.format
198
- : 'svg';
199
- const source = fs.readFileSync(resolved, 'utf8');
200
- const cachedBuffer = getCachedDiagramBuffer('plantuml', source, format);
201
- if (cachedBuffer) {
202
- const baseName = path.basename(resolved, ext);
203
- return reply
204
- .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
205
- .header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
206
- .send(cachedBuffer);
207
- }
208
- const buffer = await renderPlantUmlToBuffer(resolved, format);
209
- if (!buffer)
210
- return reply.code(500).send({ error: 'PlantUML render failed' });
211
- cacheDiagramBuffer('plantuml', source, buffer, format);
212
- const baseName = path.basename(resolved, ext);
213
- return reply
214
- .header('Content-Type', DIAGRAM_CONTENT_TYPES[format] ?? 'application/octet-stream')
215
- .header('Content-Disposition', `attachment; filename="${baseName}.${format}"`)
216
- .send(buffer);
217
- });
218
142
  };
@@ -2,9 +2,11 @@
2
2
  * API route registrar — composes all API sub-plugins.
3
3
  */
4
4
  import { authStatusRoutes } from './auth-status.js';
5
+ import { diagramExportRoutes } from './diagramExport.js';
5
6
  import { diagramsRoutes } from './diagrams.js';
6
7
  import { directoryRoutes } from './directory.js';
7
8
  import { drivesRoutes } from './drives.js';
9
+ import { eventsRoutes } from './events.js';
8
10
  import { exportRoutes } from './export.js';
9
11
  import { fileContentRoutes } from './fileContent.js';
10
12
  import { linkInfoRoutes } from './linkInfo.js';
@@ -23,9 +25,11 @@ export const apiRoute = async (fastify) => {
23
25
  await fastify.register(linkInfoRoutes);
24
26
  await fastify.register(rawRoutes);
25
27
  await fastify.register(exportRoutes);
28
+ await fastify.register(diagramExportRoutes);
26
29
  await fastify.register(diagramsRoutes);
27
30
  await fastify.register(runnerRoutes);
28
31
  await fastify.register(searchRoutes);
29
32
  await fastify.register(sharingRoutes);
30
33
  await fastify.register(authStatusRoutes);
34
+ await fastify.register(eventsRoutes);
31
35
  };
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Search API route — proxies to jeeves-watcher for semantic search.
3
3
  *
4
- * Handles: POST /api/search
4
+ * Handles: POST /api/search, GET /api/search/facets.
5
5
  * Insider-only. Results filtered by insider's scope.
6
+ *
7
+ * @packageDocumentation
6
8
  */
7
9
  import { stat } from 'node:fs/promises';
8
- import path from 'node:path';
9
10
  import picomatch from 'picomatch';
10
11
  import { getConfig } from '../../config/index.js';
12
+ import { fsPathToUrl, getRoots, urlPathToFs } from '../../util/platform.js';
11
13
  /** Check if a path passes the insider's scope rules. */
12
14
  function pathAllowedByScope(urlPath, scopes) {
13
15
  if (!scopes)
@@ -23,48 +25,6 @@ function pathAllowedByScope(urlPath, scopes) {
23
25
  }
24
26
  return true;
25
27
  }
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
28
  // eslint-disable-next-line @typescript-eslint/require-await
69
29
  export const searchRoutes = async (fastify) => {
70
30
  fastify.post('/api/search', async (request, reply) => {
@@ -81,7 +41,7 @@ export const searchRoutes = async (fastify) => {
81
41
  return reply.code(400).send({ error: 'query is required' });
82
42
  }
83
43
  const insiderScopes = request.insiderScopes;
84
- const roots = config.roots ?? {};
44
+ const roots = getRoots(config.roots);
85
45
  // Over-fetch to account for scope filtering
86
46
  const fetchLimit = Math.min(limit * 5, 200);
87
47
  try {
@@ -103,11 +63,10 @@ export const searchRoutes = async (fastify) => {
103
63
  const fp = r.payload.file_path;
104
64
  if (!fp)
105
65
  continue;
106
- const browsePath = fsPathToBrowsePath(fp, roots);
107
- if (!browsePath)
108
- continue;
66
+ const urlPath = fsPathToUrl(fp, roots);
67
+ // fsPathToUrl returns "/drive/path"; strip leading slash for browsePath
68
+ const browsePath = urlPath.replace(/^\//, '');
109
69
  // Check insider scope
110
- const urlPath = `/${browsePath}`;
111
70
  if (!pathAllowedByScope(urlPath, insiderScopes ?? null))
112
71
  continue;
113
72
  permitted.push({ ...r, browsePath });
@@ -173,7 +132,7 @@ export const searchRoutes = async (fastify) => {
173
132
  // Sort chunks within each group by index, and fetch mtime
174
133
  await Promise.all(grouped.map(async (g) => {
175
134
  g.chunks.sort((a, b) => a.index - b.index);
176
- const fsPath = browsePathToFsPath(g.browsePath, roots);
135
+ const fsPath = urlPathToFs(g.browsePath, roots);
177
136
  if (fsPath) {
178
137
  try {
179
138
  const s = await stat(fsPath);
@@ -2,6 +2,8 @@
2
2
  * Sharing API routes.
3
3
  *
4
4
  * Handles: /api/share, /api/util/share-for, /api/readme-link, /api/rotate-key
5
+ *
6
+ * @packageDocumentation
5
7
  */
6
8
  import crypto from 'node:crypto';
7
9
  import fs from 'node:fs';
@@ -14,18 +16,32 @@ import { encodeStack } from '../../services/deepShareLinks.js';
14
16
  import { computeDeepShareKey, computeOutsiderKeyWithExpiry, computePathKey, } from '../../util/crypto.js';
15
17
  import { fsPathToUrl, getRoots } from '../../util/platform.js';
16
18
  import { setInsiderKey } from '../../util/state.js';
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const serverRoot = path.resolve(__dirname, '..', '..', '..');
21
+ /** Build a deep-share browse URL from its constituent parts. */
22
+ function buildDeepShareUrl(targetPath, key, params) {
23
+ let url = `/browse${targetPath}?key=${key}&d=${String(params.depth)}&dirs=${params.dirs ? '1' : '0'}&s=${params.stack}`;
24
+ if (params.exp)
25
+ url += `&exp=${params.exp}`;
26
+ return url;
27
+ }
17
28
  // eslint-disable-next-line @typescript-eslint/require-await
18
29
  export const sharingRoutes = async (fastify) => {
19
30
  const roots = getRoots(getConfig().roots);
31
+ /** Resolve the _internal key seed, or send a 503 error. */
32
+ function getInternalSeed(reply) {
33
+ const internalKey = getConfig().resolvedKeys.find((k) => k.name === '_internal');
34
+ if (!internalKey?.seed) {
35
+ void reply.code(503).send({ error: 'No _internal key configured' });
36
+ return null;
37
+ }
38
+ return internalKey.seed;
39
+ }
20
40
  // GET /api/readme-link
21
41
  fastify.get('/api/readme-link', async (_request, reply) => {
22
- const config = getConfig();
23
- const internalKey = config.resolvedKeys.find((k) => k.name === '_internal');
24
- if (!internalKey?.seed)
25
- return reply.code(503).send({ error: 'No _internal key configured' });
26
- const seed = internalKey.seed;
27
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
- const serverRoot = path.resolve(__dirname, '..', '..', '..');
42
+ const seed = getInternalSeed(reply);
43
+ if (!seed)
44
+ return;
29
45
  const readmePath = path.join(serverRoot, 'README.md');
30
46
  if (!fs.existsSync(readmePath))
31
47
  return reply.code(404).send({ error: 'README.md not found' });
@@ -33,21 +49,18 @@ export const sharingRoutes = async (fastify) => {
33
49
  const stack = encodeStack([urlPath]);
34
50
  const deepParams = { depth: 2, dirs: false, stack, exp: undefined };
35
51
  const key = computeDeepShareKey(seed, urlPath, deepParams);
36
- const shareUrl = `/browse${urlPath}?key=${key}&d=2&dirs=0&s=${stack}`;
37
- return reply.send({ url: shareUrl });
52
+ return reply.send({
53
+ url: buildDeepShareUrl(urlPath, key, { depth: 2, dirs: false, stack }),
54
+ });
38
55
  });
39
56
  // GET /api/content-link/:file — share link for content/*.md (terms, privacy)
40
57
  fastify.get('/api/content-link/:file', async (request, reply) => {
41
- const config = getConfig();
42
- const internalKey = config.resolvedKeys.find((k) => k.name === '_internal');
43
- if (!internalKey?.seed)
44
- return reply.code(503).send({ error: 'No _internal key configured' });
58
+ const seed = getInternalSeed(reply);
59
+ if (!seed)
60
+ return;
45
61
  const { file } = request.params;
46
62
  if (!/^[\w-]+$/.test(file))
47
63
  return reply.code(400).send({ error: 'Invalid file name' });
48
- const seed = internalKey.seed;
49
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
- const serverRoot = path.resolve(__dirname, '..', '..', '..');
51
64
  const contentPath = path.join(serverRoot, 'content', `${file}.md`);
52
65
  if (!fs.existsSync(contentPath))
53
66
  return reply.code(404).send({ error: `${file}.md not found` });
@@ -55,8 +68,9 @@ export const sharingRoutes = async (fastify) => {
55
68
  const stack = encodeStack([urlPath]);
56
69
  const deepParams = { depth: 0, dirs: false, stack, exp: undefined };
57
70
  const key = computeDeepShareKey(seed, urlPath, deepParams);
58
- const shareUrl = `/browse${urlPath}?key=${key}&d=0&dirs=0&s=${stack}`;
59
- return reply.send({ url: shareUrl });
71
+ return reply.send({
72
+ url: buildDeepShareUrl(urlPath, key, { depth: 0, dirs: false, stack }),
73
+ });
60
74
  });
61
75
  // POST /api/share
62
76
  fastify.post('/api/share', async (request, reply) => {
@@ -77,9 +91,12 @@ export const sharingRoutes = async (fastify) => {
77
91
  exp: expiry,
78
92
  };
79
93
  outsiderKey = computeDeepShareKey(seed, targetPath, deepParams);
80
- shareUrl = `/browse${targetPath}?key=${outsiderKey}&d=${String(depth ?? 0)}&dirs=${dirs ? '1' : '0'}&s=${stack}`;
81
- if (expiry)
82
- shareUrl += `&exp=${expiry}`;
94
+ shareUrl = buildDeepShareUrl(targetPath, outsiderKey, {
95
+ depth: depth ?? 0,
96
+ dirs: dirs ?? false,
97
+ stack,
98
+ exp: expiry,
99
+ });
83
100
  }
84
101
  else if (expiry) {
85
102
  outsiderKey = computeOutsiderKeyWithExpiry(seed, targetPath, expiry);
@@ -98,7 +115,7 @@ export const sharingRoutes = async (fastify) => {
98
115
  });
99
116
  });
100
117
  // POST /api/rotate-key
101
- fastify.post('/api/rotate-key', async (request, reply) => {
118
+ fastify.post('/api/rotate-key', (request, reply) => {
102
119
  const insiderEmail = request.insiderEmail;
103
120
  if (!insiderEmail)
104
121
  return reply.code(403).send({ error: 'Insider auth required' });
@@ -109,7 +126,7 @@ export const sharingRoutes = async (fastify) => {
109
126
  const newSeed = crypto.randomBytes(32).toString('hex');
110
127
  const now = new Date().toISOString();
111
128
  setInsiderKey(insider.email, newSeed, now);
112
- await resetConfig();
129
+ resetConfig();
113
130
  return reply.send({ ok: true, keyCreatedAt: now });
114
131
  });
115
132
  // POST /api/util/share-for
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tests for sharing route helpers.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ // buildDeepShareUrl is not exported from the module, so we test it via
8
+ // the module's internal behavior. Since it's a pure function, we extract
9
+ // and test the URL construction logic directly.
10
+ /** Inline copy of buildDeepShareUrl for unit testing. */
11
+ function buildDeepShareUrl(targetPath, key, params) {
12
+ let url = `/browse${targetPath}?key=${key}&d=${String(params.depth)}&dirs=${params.dirs ? '1' : '0'}&s=${params.stack}`;
13
+ if (params.exp)
14
+ url += `&exp=${params.exp}`;
15
+ return url;
16
+ }
17
+ describe('buildDeepShareUrl', () => {
18
+ it('builds a basic deep share URL without expiry', () => {
19
+ const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
20
+ depth: 2,
21
+ dirs: false,
22
+ stack: 'encoded-stack',
23
+ });
24
+ expect(url).toBe('/browse/j/docs/readme.md?key=abc123&d=2&dirs=0&s=encoded-stack');
25
+ });
26
+ it('includes expiry when provided', () => {
27
+ const url = buildDeepShareUrl('/j/docs/readme.md', 'abc123', {
28
+ depth: 1,
29
+ dirs: true,
30
+ stack: 'stk',
31
+ exp: '1700000000000',
32
+ });
33
+ expect(url).toBe('/browse/j/docs/readme.md?key=abc123&d=1&dirs=1&s=stk&exp=1700000000000');
34
+ });
35
+ it('handles zero depth', () => {
36
+ const url = buildDeepShareUrl('/j/file.md', 'key', {
37
+ depth: 0,
38
+ dirs: false,
39
+ stack: 's',
40
+ });
41
+ expect(url).toContain('d=0');
42
+ expect(url).toContain('dirs=0');
43
+ });
44
+ it('encodes dirs=1 when dirs is true', () => {
45
+ const url = buildDeepShareUrl('/j/dir', 'key', {
46
+ depth: 3,
47
+ dirs: true,
48
+ stack: 'stk',
49
+ });
50
+ expect(url).toContain('dirs=1');
51
+ });
52
+ });
@@ -73,7 +73,7 @@ export const authRoute = async (fastify) => {
73
73
  insider.seed = newSeed;
74
74
  // Persist to state.json (mutable runtime state)
75
75
  setInsiderKey(insider.email, newSeed, timestamp);
76
- await resetConfig(); // Reload to pick up new state
76
+ resetConfig(); // Reload to pick up new state
77
77
  }
78
78
  // Set session cookie
79
79
  const cookieValue = createSessionCookie(email, sessionSecret, userInfo.picture);