@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,206 @@
1
+ /**
2
+ * Event route tests
3
+ */
4
+ import { JsonMap } from '@karmaniverous/jsonmap';
5
+ import Ajv from 'ajv';
6
+ import * as _ from 'radash';
7
+ import { describe, expect, it } from 'vitest';
8
+ const ajv = new Ajv();
9
+ describe('event route', () => {
10
+ describe('schema matching', () => {
11
+ it('should match against JSON Schema', () => {
12
+ const schema = {
13
+ type: 'object',
14
+ properties: {
15
+ type: { const: 'page.content_updated' },
16
+ },
17
+ required: ['type'],
18
+ };
19
+ const validate = ajv.compile(schema);
20
+ expect(validate({ type: 'page.content_updated', data: {} })).toBe(true);
21
+ expect(validate({ type: 'other' })).toBe(false);
22
+ expect(validate({ data: {} })).toBe(false);
23
+ });
24
+ it('should match first schema in order', () => {
25
+ const schema1 = {
26
+ type: 'object',
27
+ properties: {
28
+ source: { const: 'github' },
29
+ },
30
+ required: ['source'],
31
+ };
32
+ const schema2 = {
33
+ type: 'object',
34
+ properties: {
35
+ type: { const: 'generic' },
36
+ },
37
+ required: ['type'],
38
+ };
39
+ const body = { source: 'github', type: 'generic' };
40
+ const validate1 = ajv.compile(schema1);
41
+ const validate2 = ajv.compile(schema2);
42
+ // Both match, but first wins
43
+ expect(validate1(body)).toBe(true);
44
+ expect(validate2(body)).toBe(true);
45
+ });
46
+ it('should handle complex nested schemas', () => {
47
+ const schema = {
48
+ type: 'object',
49
+ properties: {
50
+ data: {
51
+ type: 'object',
52
+ properties: {
53
+ page_id: { type: 'string' },
54
+ },
55
+ required: ['page_id'],
56
+ },
57
+ },
58
+ required: ['data'],
59
+ };
60
+ const validate = ajv.compile(schema);
61
+ expect(validate({ data: { page_id: 'abc123' } })).toBe(true);
62
+ expect(validate({ data: {} })).toBe(false);
63
+ expect(validate({})).toBe(false);
64
+ });
65
+ });
66
+ describe('JsonMap transformation', () => {
67
+ it('should extract fields using radash get', async () => {
68
+ const body = {
69
+ type: 'page.content_updated',
70
+ data: {
71
+ page_id: 'abc123',
72
+ title: 'Test Page',
73
+ },
74
+ };
75
+ const map = {
76
+ pageId: {
77
+ $: { method: '$.lib._.get', params: ['$.input', 'data.page_id'] },
78
+ },
79
+ type: {
80
+ $: { method: '$.lib._.get', params: ['$.input', 'type'] },
81
+ },
82
+ };
83
+ const mapper = new JsonMap(map, { _: _ });
84
+ const result = (await mapper.transform(body));
85
+ expect(result.pageId).toBe('abc123');
86
+ expect(result.type).toBe('page.content_updated');
87
+ });
88
+ it('should handle missing fields gracefully', async () => {
89
+ const body = {
90
+ type: 'event',
91
+ };
92
+ const map = {
93
+ pageId: {
94
+ $: { method: '$.lib._.get', params: ['$.input', 'data.page_id'] },
95
+ },
96
+ type: {
97
+ $: { method: '$.lib._.get', params: ['$.input', 'type'] },
98
+ },
99
+ };
100
+ const mapper = new JsonMap(map, { _: _ });
101
+ const result = (await mapper.transform(body));
102
+ expect(result.pageId).toBeUndefined();
103
+ expect(result.type).toBe('event');
104
+ });
105
+ it('should preserve full body when no map is defined', () => {
106
+ const body = {
107
+ type: 'event',
108
+ data: { foo: 'bar' },
109
+ nested: { a: { b: 'c' } },
110
+ };
111
+ // No transformation when map is undefined
112
+ const result = body;
113
+ expect(result).toEqual(body);
114
+ expect(result.data).toEqual({ foo: 'bar' });
115
+ expect(result.nested).toEqual({ a: { b: 'c' } });
116
+ });
117
+ it('should handle nested path extraction', async () => {
118
+ const body = {
119
+ metadata: {
120
+ author: {
121
+ name: 'Alice',
122
+ email: 'alice@example.com',
123
+ },
124
+ tags: ['important', 'review'],
125
+ },
126
+ };
127
+ const map = {
128
+ authorName: {
129
+ $: {
130
+ method: '$.lib._.get',
131
+ params: ['$.input', 'metadata.author.name'],
132
+ },
133
+ },
134
+ authorEmail: {
135
+ $: {
136
+ method: '$.lib._.get',
137
+ params: ['$.input', 'metadata.author.email'],
138
+ },
139
+ },
140
+ };
141
+ const mapper = new JsonMap(map, { _: _ });
142
+ const result = (await mapper.transform(body));
143
+ expect(result.authorName).toBe('Alice');
144
+ expect(result.authorEmail).toBe('alice@example.com');
145
+ });
146
+ });
147
+ describe('queue entry formatting', () => {
148
+ it('should format entry with all required fields', () => {
149
+ const entry = {
150
+ ts: new Date().toISOString(),
151
+ event: 'test-event',
152
+ cmd: 'node test.js',
153
+ body: { foo: 'bar' },
154
+ timeoutMs: 30000,
155
+ };
156
+ expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
157
+ expect(entry.event).toBe('test-event');
158
+ expect(entry.cmd).toBe('node test.js');
159
+ expect(entry.body).toEqual({ foo: 'bar' });
160
+ expect(entry.timeoutMs).toBe(30000);
161
+ });
162
+ it('should use default timeout when not specified', () => {
163
+ const defaultTimeoutMs = 30000;
164
+ const eventConfig = {}; // Simulate config without timeoutMs
165
+ const timeoutMs = eventConfig.timeoutMs ?? defaultTimeoutMs;
166
+ expect(timeoutMs).toBe(30000);
167
+ });
168
+ it('should use event-specific timeout when specified', () => {
169
+ const defaultTimeoutMs = 30000;
170
+ const eventConfig = { timeoutMs: 60000 };
171
+ const timeoutMs = eventConfig.timeoutMs ?? defaultTimeoutMs;
172
+ expect(timeoutMs).toBe(60000);
173
+ });
174
+ });
175
+ describe('event log formatting', () => {
176
+ it('should format matched event log entry', () => {
177
+ const entry = {
178
+ ts: new Date().toISOString(),
179
+ event: 'test-event',
180
+ matched: true,
181
+ exitCode: 0,
182
+ durationMs: 1234,
183
+ };
184
+ expect(entry.matched).toBe(true);
185
+ expect(entry.event).toBe('test-event');
186
+ expect(entry.exitCode).toBe(0);
187
+ expect(entry.durationMs).toBe(1234);
188
+ });
189
+ it('should format unmatched event log entry', () => {
190
+ const entry = {
191
+ ts: new Date().toISOString(),
192
+ event: null,
193
+ matched: false,
194
+ bodyPreview: '{"type":"unknown"}',
195
+ };
196
+ expect(entry.matched).toBe(false);
197
+ expect(entry.event).toBeNull();
198
+ expect(entry.bodyPreview).toBe('{"type":"unknown"}');
199
+ });
200
+ it('should truncate body preview to reasonable length', () => {
201
+ const longBody = { data: 'x'.repeat(300) };
202
+ const preview = JSON.stringify(longBody).slice(0, 200);
203
+ expect(preview.length).toBe(200);
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Health check endpoint
3
+ */
4
+ // eslint-disable-next-line @typescript-eslint/require-await
5
+ export const healthRoute = async (fastify) => {
6
+ // eslint-disable-next-line @typescript-eslint/require-await
7
+ fastify.get('/health', async () => {
8
+ return { ok: true, uptime: process.uptime() };
9
+ });
10
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Key generation and management endpoints (legacy API-key-header auth).
3
+ *
4
+ * These endpoints use X-API-Key header auth (raw seed) for machine-to-machine
5
+ * access. The SPA equivalents in api/sharing.ts use cookie/URL-key auth.
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import { findInsider, resolveInsiderKeyAuth, resolveSessionAuth, } from '../auth/resolve.js';
9
+ import { getConfig, resetConfig } from '../config/index.js';
10
+ import { appendEvent } from '../services/eventQueue.js';
11
+ import { computeInsiderKey, computeOutsiderKeyWithExpiry, computePathKey, timingSafeEqual, } from '../util/crypto.js';
12
+ import { setInsiderKey, setKeyRotationTimestamp } from '../util/state.js';
13
+ // eslint-disable-next-line @typescript-eslint/require-await
14
+ export const keysRoute = async (fastify) => {
15
+ // GET /key - Compute path-specific outsider key (requires raw API key seed in header)
16
+ fastify.get('/key', async (request, reply) => {
17
+ const provided = request.headers['x-api-key'];
18
+ const config = getConfig();
19
+ if (!provided) {
20
+ return reply.code(401).send({ error: 'X-API-Key header required' });
21
+ }
22
+ const matched = config.resolvedKeys.find((rk) => timingSafeEqual(provided, rk.seed));
23
+ if (!matched) {
24
+ return reply.code(401).send({ error: 'Invalid API key' });
25
+ }
26
+ const targetPath = request.query.path;
27
+ if (!targetPath) {
28
+ return reply.code(400).send({ error: 'path query param required' });
29
+ }
30
+ const key = computePathKey(matched.seed, targetPath);
31
+ return { path: targetPath, key };
32
+ });
33
+ // GET /insider-key - Generate insider key (requires raw API key seed in header)
34
+ fastify.get('/insider-key', async (request, reply) => {
35
+ const provided = request.headers['x-api-key'];
36
+ const config = getConfig();
37
+ if (!provided) {
38
+ return reply.code(401).send({ error: 'X-API-Key header required' });
39
+ }
40
+ const matched = config.resolvedKeys.find((rk) => timingSafeEqual(provided, rk.seed));
41
+ if (!matched) {
42
+ return reply.code(401).send({ error: 'Invalid API key' });
43
+ }
44
+ const key = computeInsiderKey(matched.seed);
45
+ return { key };
46
+ });
47
+ // POST /rotate-key - Rotate key (insider key or session cookie)
48
+ fastify.post('/rotate-key', async (request, reply) => {
49
+ const provided = request.query.key;
50
+ const config = getConfig();
51
+ if (provided) {
52
+ // Try insider key auth
53
+ const insiderResult = resolveInsiderKeyAuth(config, provided);
54
+ if (insiderResult.valid && insiderResult.email) {
55
+ // Insider key rotation
56
+ return rotateInsiderSeed(insiderResult.email, config);
57
+ }
58
+ // Machine key rotation is not supported with TS config
59
+ const matched = config.resolvedKeys.find((rk) => timingSafeEqual(provided, computeInsiderKey(rk.seed)));
60
+ if (matched) {
61
+ return reply.code(501).send({
62
+ error: 'Machine key rotation is not supported with TypeScript config. ' +
63
+ 'Update the key manually in jeeves.config.ts and restart the server.',
64
+ });
65
+ }
66
+ }
67
+ // Try session-based auth
68
+ const sessionResult = resolveSessionAuth(config, request);
69
+ if (sessionResult.valid && sessionResult.email) {
70
+ return rotateInsiderSeed(sessionResult.email, config);
71
+ }
72
+ return reply.code(401).send({ error: 'Invalid insider key' });
73
+ });
74
+ // GET /share - Generate outsider link (insider key or session cookie)
75
+ fastify.get('/share', async (request, reply) => {
76
+ const config = getConfig();
77
+ const targetPath = request.query.path;
78
+ if (!targetPath) {
79
+ return reply.code(400).send({ error: 'path query param required' });
80
+ }
81
+ // Try provided key
82
+ if (request.query.key) {
83
+ const insiderResult = resolveInsiderKeyAuth(config, request.query.key);
84
+ if (insiderResult.valid && insiderResult.seed) {
85
+ return buildShareResponse(insiderResult.seed, targetPath, request.query.exp);
86
+ }
87
+ }
88
+ // Try session cookie
89
+ const sessionResult = resolveSessionAuth(config, request);
90
+ if (sessionResult.valid && sessionResult.seed) {
91
+ return buildShareResponse(sessionResult.seed, targetPath, request.query.exp);
92
+ }
93
+ return reply.code(401).send({ error: 'Invalid insider key' });
94
+ });
95
+ };
96
+ function rotateInsiderSeed(email, config) {
97
+ const insider = findInsider(config.resolvedInsiders, email);
98
+ if (!insider?.seed)
99
+ return { ok: false, error: 'Insider not found' };
100
+ const rotatedSeed = crypto.randomBytes(32).toString('hex');
101
+ const timestamp = new Date().toISOString();
102
+ setInsiderKey(insider.email, rotatedSeed, timestamp);
103
+ appendEvent({
104
+ kind: 'insider_key_rotated',
105
+ email: insider.email,
106
+ at: timestamp,
107
+ });
108
+ setKeyRotationTimestamp(timestamp);
109
+ resetConfig();
110
+ return { ok: true, keyName: insider.email };
111
+ }
112
+ function buildShareResponse(seed, targetPath, expiry) {
113
+ let outsiderKey;
114
+ let shareUrl;
115
+ if (expiry) {
116
+ outsiderKey = computeOutsiderKeyWithExpiry(seed, targetPath, expiry);
117
+ shareUrl = `/browse${targetPath}?key=${outsiderKey}&exp=${expiry}`;
118
+ }
119
+ else {
120
+ outsiderKey = computePathKey(seed, targetPath);
121
+ shareUrl = `/browse${targetPath}?key=${outsiderKey}`;
122
+ }
123
+ return {
124
+ path: targetPath,
125
+ key: outsiderKey,
126
+ exp: expiry ?? null,
127
+ url: shareUrl,
128
+ };
129
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Legacy /path redirect — sends all /path/* requests to /browse/*
3
+ */
4
+ // eslint-disable-next-line @typescript-eslint/require-await
5
+ export const pathRoute = async (fastify) => {
6
+ // Redirect /path (root) to /browse
7
+ fastify.get('/path', async (_request, reply) => {
8
+ return reply.redirect('/browse');
9
+ });
10
+ // Redirect /path/* to /browse/*
11
+ fastify.get('/path/*', async (request, reply) => {
12
+ const reqPath = request.params['*'];
13
+ const url = new URL(request.url, 'http://localhost');
14
+ const query = url.search;
15
+ return reply.redirect(`/browse/${reqPath}${query}`);
16
+ });
17
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Static asset routes — serves locally-bundled libraries from node_modules.
3
+ * Eliminates CDN dependencies for Lucide, Panzoom, and highlight.js.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ // eslint-disable-next-line @typescript-eslint/require-await
10
+ export const staticRoutes = async (fastify) => {
11
+ // robots.txt — block all crawlers
12
+ fastify.get('/robots.txt', async (_request, reply) => {
13
+ reply.type('text/plain').send('User-agent: *\nDisallow: /\n');
14
+ });
15
+ // Favicon
16
+ fastify.get('/favicon.svg', async (_request, reply) => {
17
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="90">🎩</text></svg>`;
18
+ reply.type('image/svg+xml').send(svg);
19
+ });
20
+ // Lucide icons
21
+ fastify.get('/static/lucide.min.js', async (_request, reply) => {
22
+ const filePath = path.join(__dirname, '..', 'node_modules', 'lucide', 'dist', 'umd', 'lucide.min.js');
23
+ return reply.type('application/javascript').send(fs.readFileSync(filePath));
24
+ });
25
+ // Panzoom
26
+ fastify.get('/static/panzoom.min.js', async (_request, reply) => {
27
+ const filePath = path.join(__dirname, '..', 'node_modules', '@panzoom', 'panzoom', 'dist', 'panzoom.min.js');
28
+ return reply.type('application/javascript').send(fs.readFileSync(filePath));
29
+ });
30
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Jeeves Server - Main entry point
3
+ * Fastify server for webhooks, file serving, and markdown rendering
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import cookie from '@fastify/cookie';
9
+ import fastifyStatic from '@fastify/static';
10
+ import Fastify from 'fastify';
11
+ import { getConfig, initConfig, isConfigInitialized } from './config/index.js';
12
+ import { apiRoute } from './routes/api/index.js';
13
+ import { authRoute } from './routes/auth.js';
14
+ import { eventRoute } from './routes/event.js';
15
+ import { healthRoute } from './routes/health.js';
16
+ import { keysRoute } from './routes/keys.js';
17
+ import { pathRoute } from './routes/path/index.js';
18
+ import { staticRoutes } from './routes/static.js';
19
+ import { initDiagramCache } from './services/diagramCache.js';
20
+ import { startQueueProcessor } from './services/eventQueue.js';
21
+ import { initExportCache } from './services/exportCache.js';
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ async function start() {
24
+ try {
25
+ const config = isConfigInitialized() ? getConfig() : await initConfig();
26
+ const fastify = Fastify({
27
+ logger: true,
28
+ });
29
+ // Register plugins
30
+ await fastify.register(cookie);
31
+ // X-Robots-Tag on all responses — invisible to search engines
32
+ fastify.addHook('onSend', async (_request, reply) => {
33
+ void reply.header('X-Robots-Tag', 'noindex, nofollow');
34
+ });
35
+ // Register routes
36
+ await fastify.register(staticRoutes);
37
+ await fastify.register(healthRoute);
38
+ await fastify.register(authRoute);
39
+ await fastify.register(keysRoute);
40
+ await fastify.register(eventRoute);
41
+ await fastify.register(apiRoute);
42
+ await fastify.register(pathRoute);
43
+ // Serve React SPA (if built)
44
+ const clientDir = path.join(__dirname, '..', 'client');
45
+ if (fs.existsSync(clientDir)) {
46
+ await fastify.register(fastifyStatic, {
47
+ root: clientDir,
48
+ prefix: '/app/',
49
+ });
50
+ // SPA fallback for React routes
51
+ fastify.get('/', async (_request, reply) => {
52
+ return reply.sendFile('index.html', clientDir);
53
+ });
54
+ fastify.get('/browse', async (_request, reply) => {
55
+ return reply.sendFile('index.html', clientDir);
56
+ });
57
+ fastify.get('/browse/*', async (_request, reply) => {
58
+ return reply.sendFile('index.html', clientDir);
59
+ });
60
+ fastify.get('/runner', async (_request, reply) => {
61
+ return reply.sendFile('index.html', clientDir);
62
+ });
63
+ fastify.get('/runner/*', async (_request, reply) => {
64
+ return reply.sendFile('index.html', clientDir);
65
+ });
66
+ }
67
+ // Initialize caches
68
+ initDiagramCache(config.diagramCachePath);
69
+ initExportCache();
70
+ // Start queue processor
71
+ startQueueProcessor();
72
+ await fastify.listen({ port: config.port, host: '0.0.0.0' });
73
+ console.log(`Jeeves server listening on port ${String(config.port)}`);
74
+ console.log(`Endpoints:`);
75
+ console.log(` GET /browse/* - File browser SPA`);
76
+ console.log(` GET /api/raw/* - Raw file serving`);
77
+ console.log(` GET /api/export/* - PDF/DOCX/ZIP export`);
78
+ console.log(` POST /event - Event Gateway (key auth)`);
79
+ console.log(` GET /key - Compute path key (X-API-Key auth)`);
80
+ console.log(` GET /health - Health check (no auth)`);
81
+ }
82
+ catch (err) {
83
+ console.error('Fatal startup error:', err);
84
+ process.exit(1);
85
+ }
86
+ }
87
+ start().catch((err) => {
88
+ console.error('Unhandled startup error:', err);
89
+ process.exit(1);
90
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Deep share link rewriting for outsider access with depth traversal.
3
+ *
4
+ * Rewrites outgoing links in rendered HTML with computed sub-keys,
5
+ * enabling outsiders to follow links up to N levels deep.
6
+ */
7
+ import * as cheerio from 'cheerio';
8
+ import LZString from 'lz-string';
9
+ import { computeDeepShareKey } from '../util/crypto.js';
10
+ /**
11
+ * Parse a compressed stack string into an array of paths.
12
+ */
13
+ export function decodeStack(compressed) {
14
+ if (!compressed)
15
+ return [];
16
+ const json = LZString.decompressFromEncodedURIComponent(compressed);
17
+ if (!json)
18
+ return [];
19
+ try {
20
+ const parsed = JSON.parse(json);
21
+ return Array.isArray(parsed) ? parsed : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ }
27
+ /**
28
+ * Encode a path stack array into a compressed string.
29
+ */
30
+ export function encodeStack(stack) {
31
+ return LZString.compressToEncodedURIComponent(JSON.stringify(stack));
32
+ }
33
+ /**
34
+ * Compute the remaining depth for a given stack.
35
+ */
36
+ export function remainingDepth(maxDepth, stack) {
37
+ return maxDepth - (stack.length - 1);
38
+ }
39
+ /**
40
+ * Compute a sub-link URL for an outgoing link target.
41
+ * Returns null if the link should be stripped (depth exhausted or type not allowed).
42
+ */
43
+ export function computeSubLink(seed, targetUrlPath, currentStack, maxDepth, dirs, exp, isDirectory) {
44
+ // Check if directories are allowed
45
+ if (isDirectory && !dirs)
46
+ return null;
47
+ // Compute new stack
48
+ const existingIndex = currentStack.indexOf(targetUrlPath);
49
+ let newStack;
50
+ if (existingIndex >= 0) {
51
+ // Revisiting — truncate stack to that point
52
+ newStack = currentStack.slice(0, existingIndex + 1);
53
+ }
54
+ else {
55
+ // New page — append
56
+ newStack = [...currentStack, targetUrlPath];
57
+ }
58
+ // Check remaining depth
59
+ const remaining = remainingDepth(maxDepth, newStack);
60
+ if (remaining < 0)
61
+ return null;
62
+ const compressedStack = encodeStack(newStack);
63
+ // Compute key for the target
64
+ const params = {
65
+ depth: maxDepth,
66
+ dirs,
67
+ stack: compressedStack,
68
+ exp,
69
+ };
70
+ const key = computeDeepShareKey(seed, targetUrlPath, params);
71
+ // Build URL
72
+ let url = `/browse${targetUrlPath}?key=${key}&d=${String(maxDepth)}&dirs=${dirs ? '1' : '0'}&s=${compressedStack}`;
73
+ if (exp)
74
+ url += `&exp=${exp}`;
75
+ return url;
76
+ }
77
+ /**
78
+ * Rewrite outgoing links in rendered HTML for deep share access.
79
+ *
80
+ * For outsiders with depth \> 0:
81
+ * - Internal links get rewritten with sub-keys
82
+ * - Links beyond depth get stripped (text preserved, link removed)
83
+ * - External links (http/https) are left unchanged
84
+ * - Anchor links (#) are left unchanged
85
+ */
86
+ export function rewriteLinksForDeepShare(html, seed, currentPath, maxDepth, dirs, stackCompressed, exp) {
87
+ const currentStack = decodeStack(stackCompressed);
88
+ // Ensure current path is in the stack
89
+ if (currentStack.length === 0 ||
90
+ currentStack[currentStack.length - 1] !== currentPath) {
91
+ currentStack.push(currentPath);
92
+ }
93
+ const remaining = remainingDepth(maxDepth, currentStack);
94
+ const $ = cheerio.load(html, null, false);
95
+ // Rewrite <a> tags
96
+ $('a').each((_i, el) => {
97
+ const $el = $(el);
98
+ const href = $el.attr('href');
99
+ if (!href)
100
+ return;
101
+ // Skip external links, anchors, and data URLs
102
+ if (href.startsWith('http://') ||
103
+ href.startsWith('https://') ||
104
+ href.startsWith('#') ||
105
+ href.startsWith('//') ||
106
+ href.startsWith('data:') ||
107
+ href.startsWith('mailto:')) {
108
+ return;
109
+ }
110
+ // If no remaining depth, strip the link (keep text)
111
+ if (remaining <= 0) {
112
+ $el.replaceWith($el.html() ?? '');
113
+ return;
114
+ }
115
+ // Raw file links — leave as-is (these are for images/downloads)
116
+ if (href.startsWith('/api/raw/'))
117
+ return;
118
+ // Determine target path
119
+ let targetPath;
120
+ if (href.startsWith('/browse/')) {
121
+ targetPath = '/' + href.replace('/browse/', '').split('?')[0];
122
+ }
123
+ else if (href.startsWith('/')) {
124
+ targetPath = href.split('?')[0];
125
+ }
126
+ else {
127
+ // Relative link — resolve against current path directory
128
+ const dir = currentPath.substring(0, currentPath.lastIndexOf('/'));
129
+ targetPath = dir
130
+ ? `${dir}/${href.split('?')[0]}`
131
+ : `/${href.split('?')[0]}`;
132
+ }
133
+ // Normalize
134
+ targetPath = targetPath.replace(/\/+/g, '/');
135
+ const isDirectory = targetPath.endsWith('/');
136
+ const subLink = computeSubLink(seed, targetPath, currentStack, maxDepth, dirs, exp, isDirectory);
137
+ if (subLink === null) {
138
+ // Strip link, keep text
139
+ $el.replaceWith($el.html() ?? '');
140
+ }
141
+ else {
142
+ $el.attr('href', subLink);
143
+ }
144
+ });
145
+ // Rewrite <img> src for images that use /api/raw/ — add key auth
146
+ $('img').each((_i, el) => {
147
+ const $el = $(el);
148
+ const src = $el.attr('src');
149
+ if (!src || !src.startsWith('/api/raw/'))
150
+ return;
151
+ const params = {
152
+ depth: maxDepth,
153
+ dirs,
154
+ stack: stackCompressed,
155
+ exp,
156
+ };
157
+ const rawPath = '/' + src.replace('/api/raw/', '').split('?')[0];
158
+ const key = computeDeepShareKey(seed, rawPath, params);
159
+ const authSrc = `${src}${src.includes('?') ? '&' : '?'}key=${key}&d=${String(maxDepth)}&dirs=${dirs ? '1' : '0'}&s=${stackCompressed}${exp ? `&exp=${exp}` : ''}`;
160
+ $el.attr('src', authSrc);
161
+ });
162
+ return $.html();
163
+ }