@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,248 @@
1
+ /**
2
+ * Event route tests
3
+ */
4
+
5
+ import { JsonMap } from '@karmaniverous/jsonmap';
6
+ import Ajv from 'ajv';
7
+ import * as _ from 'radash';
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ const ajv = new Ajv();
11
+
12
+ describe('event route', () => {
13
+ describe('schema matching', () => {
14
+ it('should match against JSON Schema', () => {
15
+ const schema = {
16
+ type: 'object',
17
+ properties: {
18
+ type: { const: 'page.content_updated' },
19
+ },
20
+ required: ['type'],
21
+ };
22
+
23
+ const validate = ajv.compile(schema);
24
+
25
+ expect(validate({ type: 'page.content_updated', data: {} })).toBe(true);
26
+ expect(validate({ type: 'other' })).toBe(false);
27
+ expect(validate({ data: {} })).toBe(false);
28
+ });
29
+
30
+ it('should match first schema in order', () => {
31
+ const schema1 = {
32
+ type: 'object',
33
+ properties: {
34
+ source: { const: 'github' },
35
+ },
36
+ required: ['source'],
37
+ };
38
+
39
+ const schema2 = {
40
+ type: 'object',
41
+ properties: {
42
+ type: { const: 'generic' },
43
+ },
44
+ required: ['type'],
45
+ };
46
+
47
+ const body = { source: 'github', type: 'generic' };
48
+
49
+ const validate1 = ajv.compile(schema1);
50
+ const validate2 = ajv.compile(schema2);
51
+
52
+ // Both match, but first wins
53
+ expect(validate1(body)).toBe(true);
54
+ expect(validate2(body)).toBe(true);
55
+ });
56
+
57
+ it('should handle complex nested schemas', () => {
58
+ const schema = {
59
+ type: 'object',
60
+ properties: {
61
+ data: {
62
+ type: 'object',
63
+ properties: {
64
+ page_id: { type: 'string' },
65
+ },
66
+ required: ['page_id'],
67
+ },
68
+ },
69
+ required: ['data'],
70
+ };
71
+
72
+ const validate = ajv.compile(schema);
73
+
74
+ expect(validate({ data: { page_id: 'abc123' } })).toBe(true);
75
+ expect(validate({ data: {} })).toBe(false);
76
+ expect(validate({})).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe('JsonMap transformation', () => {
81
+ it('should extract fields using radash get', async () => {
82
+ const body = {
83
+ type: 'page.content_updated',
84
+ data: {
85
+ page_id: 'abc123',
86
+ title: 'Test Page',
87
+ },
88
+ };
89
+
90
+ const map = {
91
+ pageId: {
92
+ $: { method: '$.lib._.get', params: ['$.input', 'data.page_id'] },
93
+ },
94
+ type: {
95
+ $: { method: '$.lib._.get', params: ['$.input', 'type'] },
96
+ },
97
+ };
98
+
99
+ const mapper = new JsonMap(map, { _: _ as never });
100
+ const result = (await mapper.transform(body)) as Record<string, unknown>;
101
+
102
+ expect(result.pageId).toBe('abc123');
103
+ expect(result.type).toBe('page.content_updated');
104
+ });
105
+
106
+ it('should handle missing fields gracefully', async () => {
107
+ const body = {
108
+ type: 'event',
109
+ };
110
+
111
+ const map = {
112
+ pageId: {
113
+ $: { method: '$.lib._.get', params: ['$.input', 'data.page_id'] },
114
+ },
115
+ type: {
116
+ $: { method: '$.lib._.get', params: ['$.input', 'type'] },
117
+ },
118
+ };
119
+
120
+ const mapper = new JsonMap(map, { _: _ as never });
121
+ const result = (await mapper.transform(body)) as Record<string, unknown>;
122
+
123
+ expect(result.pageId).toBeUndefined();
124
+ expect(result.type).toBe('event');
125
+ });
126
+
127
+ it('should preserve full body when no map is defined', () => {
128
+ const body = {
129
+ type: 'event',
130
+ data: { foo: 'bar' },
131
+ nested: { a: { b: 'c' } },
132
+ };
133
+
134
+ // No transformation when map is undefined
135
+ const result = body;
136
+
137
+ expect(result).toEqual(body);
138
+ expect(result.data).toEqual({ foo: 'bar' });
139
+ expect(result.nested).toEqual({ a: { b: 'c' } });
140
+ });
141
+
142
+ it('should handle nested path extraction', async () => {
143
+ const body = {
144
+ metadata: {
145
+ author: {
146
+ name: 'Alice',
147
+ email: 'alice@example.com',
148
+ },
149
+ tags: ['important', 'review'],
150
+ },
151
+ };
152
+
153
+ const map = {
154
+ authorName: {
155
+ $: {
156
+ method: '$.lib._.get',
157
+ params: ['$.input', 'metadata.author.name'],
158
+ },
159
+ },
160
+ authorEmail: {
161
+ $: {
162
+ method: '$.lib._.get',
163
+ params: ['$.input', 'metadata.author.email'],
164
+ },
165
+ },
166
+ };
167
+
168
+ const mapper = new JsonMap(map, { _: _ as never });
169
+ const result = (await mapper.transform(body)) as Record<string, unknown>;
170
+
171
+ expect(result.authorName).toBe('Alice');
172
+ expect(result.authorEmail).toBe('alice@example.com');
173
+ });
174
+ });
175
+
176
+ describe('queue entry formatting', () => {
177
+ it('should format entry with all required fields', () => {
178
+ const entry = {
179
+ ts: new Date().toISOString(),
180
+ event: 'test-event',
181
+ cmd: 'node test.js',
182
+ body: { foo: 'bar' },
183
+ timeoutMs: 30000,
184
+ };
185
+
186
+ expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
187
+ expect(entry.event).toBe('test-event');
188
+ expect(entry.cmd).toBe('node test.js');
189
+ expect(entry.body).toEqual({ foo: 'bar' });
190
+ expect(entry.timeoutMs).toBe(30000);
191
+ });
192
+
193
+ it('should use default timeout when not specified', () => {
194
+ const defaultTimeoutMs = 30000;
195
+ const eventConfig: { timeoutMs?: number } = {}; // Simulate config without timeoutMs
196
+
197
+ const timeoutMs = eventConfig.timeoutMs ?? defaultTimeoutMs;
198
+
199
+ expect(timeoutMs).toBe(30000);
200
+ });
201
+
202
+ it('should use event-specific timeout when specified', () => {
203
+ const defaultTimeoutMs = 30000;
204
+ const eventConfig: { timeoutMs?: number } = { timeoutMs: 60000 };
205
+
206
+ const timeoutMs = eventConfig.timeoutMs ?? defaultTimeoutMs;
207
+
208
+ expect(timeoutMs).toBe(60000);
209
+ });
210
+ });
211
+
212
+ describe('event log formatting', () => {
213
+ it('should format matched event log entry', () => {
214
+ const entry = {
215
+ ts: new Date().toISOString(),
216
+ event: 'test-event',
217
+ matched: true,
218
+ exitCode: 0,
219
+ durationMs: 1234,
220
+ };
221
+
222
+ expect(entry.matched).toBe(true);
223
+ expect(entry.event).toBe('test-event');
224
+ expect(entry.exitCode).toBe(0);
225
+ expect(entry.durationMs).toBe(1234);
226
+ });
227
+
228
+ it('should format unmatched event log entry', () => {
229
+ const entry = {
230
+ ts: new Date().toISOString(),
231
+ event: null,
232
+ matched: false,
233
+ bodyPreview: '{"type":"unknown"}',
234
+ };
235
+
236
+ expect(entry.matched).toBe(false);
237
+ expect(entry.event).toBeNull();
238
+ expect(entry.bodyPreview).toBe('{"type":"unknown"}');
239
+ });
240
+
241
+ it('should truncate body preview to reasonable length', () => {
242
+ const longBody = { data: 'x'.repeat(300) };
243
+ const preview = JSON.stringify(longBody).slice(0, 200);
244
+
245
+ expect(preview.length).toBe(200);
246
+ });
247
+ });
248
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Event Gateway webhook endpoint
3
+ */
4
+
5
+ import { JsonMap } from '@karmaniverous/jsonmap';
6
+ import Ajv from 'ajv';
7
+ import type { FastifyPluginAsync } from 'fastify';
8
+ import * as _ from 'radash';
9
+
10
+ import { verifyKey } from '../auth/keys.js';
11
+ import { getConfig } from '../config/index.js';
12
+ import { logEvent } from '../services/eventLog.js';
13
+ import { enqueue } from '../services/eventQueue.js';
14
+
15
+ const ajv = new Ajv();
16
+
17
+ interface EventRequest {
18
+ Body: Record<string, unknown>;
19
+ Querystring: { key?: string };
20
+ }
21
+
22
+ /**
23
+ * Match request body against configured event schemas
24
+ */
25
+ function matchEvent(
26
+ body: Record<string, unknown>,
27
+ ): { name: string; cmd: string; map?: object; timeoutMs: number } | null {
28
+ const { events, eventTimeoutMs } = getConfig();
29
+
30
+ for (const [name, eventConfig] of Object.entries(events)) {
31
+ const validate = ajv.compile(eventConfig.schema);
32
+
33
+ if (validate(body)) {
34
+ return {
35
+ name,
36
+ cmd: eventConfig.cmd,
37
+ map: eventConfig.map,
38
+ timeoutMs: eventConfig.timeoutMs ?? eventTimeoutMs,
39
+ };
40
+ }
41
+ }
42
+
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Transform body using JsonMap (if map is defined)
48
+ */
49
+ async function transformBody(
50
+ body: Record<string, unknown>,
51
+ map?: object,
52
+ ): Promise<Record<string, unknown>> {
53
+ if (!map) return body;
54
+
55
+ // JsonMap with radash available as $.lib._
56
+ const mapper = new JsonMap(map, { _: _ as never });
57
+ const result = await mapper.transform(body);
58
+ return result as Record<string, unknown>;
59
+ }
60
+
61
+ // eslint-disable-next-line @typescript-eslint/require-await
62
+ export const eventRoute: FastifyPluginAsync = async (fastify) => {
63
+ // Auth middleware
64
+ fastify.addHook('preHandler', async (request, reply) => {
65
+ if (!request.url.startsWith('/event')) return;
66
+
67
+ const provided = (request.query as { key?: string }).key;
68
+ const config = getConfig();
69
+
70
+ const result = verifyKey(
71
+ config.resolvedKeys,
72
+ '/event',
73
+ provided,
74
+ undefined,
75
+ );
76
+
77
+ if (!result.valid) {
78
+ reply.code(401).send({ error: 'Unauthorized' });
79
+ }
80
+ });
81
+
82
+ // Event endpoint
83
+ fastify.post<EventRequest>('/event', async (request) => {
84
+ const body = request.body;
85
+
86
+ // Match against configured events
87
+ const match = matchEvent(body);
88
+
89
+ if (match) {
90
+ // Transform body if map is defined
91
+ const transformedBody = await transformBody(body, match.map);
92
+
93
+ // Enqueue for processing
94
+ enqueue(match.name, match.cmd, transformedBody, match.timeoutMs);
95
+
96
+ return { matched: match.name };
97
+ } else {
98
+ // Log unmatched event
99
+ logEvent({
100
+ event: null,
101
+ matched: false,
102
+ bodyPreview: JSON.stringify(body),
103
+ });
104
+
105
+ // Always return 200 for unmatched events (prevents webhook retry/disable)
106
+ return { matched: null };
107
+ }
108
+ });
109
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Health check endpoint
3
+ */
4
+
5
+ import type { FastifyPluginAsync } from 'fastify';
6
+
7
+ // eslint-disable-next-line @typescript-eslint/require-await
8
+ export const healthRoute: FastifyPluginAsync = async (fastify) => {
9
+ // eslint-disable-next-line @typescript-eslint/require-await
10
+ fastify.get('/health', async () => {
11
+ return { ok: true, uptime: process.uptime() };
12
+ });
13
+ };
@@ -0,0 +1,192 @@
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
+
8
+ import crypto from 'node:crypto';
9
+
10
+ import type { FastifyPluginAsync } from 'fastify';
11
+
12
+ import {
13
+ findInsider,
14
+ resolveInsiderKeyAuth,
15
+ resolveSessionAuth,
16
+ } from '../auth/resolve.js';
17
+ import { getConfig, resetConfig } from '../config/index.js';
18
+ import { appendEvent } from '../services/eventQueue.js';
19
+ import {
20
+ computeInsiderKey,
21
+ computeOutsiderKeyWithExpiry,
22
+ computePathKey,
23
+ timingSafeEqual,
24
+ } from '../util/crypto.js';
25
+ import { setInsiderKey, setKeyRotationTimestamp } from '../util/state.js';
26
+
27
+ // eslint-disable-next-line @typescript-eslint/require-await
28
+ export const keysRoute: FastifyPluginAsync = async (fastify) => {
29
+ // GET /key - Compute path-specific outsider key (requires raw API key seed in header)
30
+ fastify.get<{ Querystring: { path?: string } }>(
31
+ '/key',
32
+ async (request, reply) => {
33
+ const provided = request.headers['x-api-key'] as string;
34
+ const config = getConfig();
35
+
36
+ if (!provided) {
37
+ return reply.code(401).send({ error: 'X-API-Key header required' });
38
+ }
39
+
40
+ const matched = config.resolvedKeys.find((rk) =>
41
+ timingSafeEqual(provided, rk.seed),
42
+ );
43
+ if (!matched) {
44
+ return reply.code(401).send({ error: 'Invalid API key' });
45
+ }
46
+
47
+ const targetPath = request.query.path;
48
+ if (!targetPath) {
49
+ return reply.code(400).send({ error: 'path query param required' });
50
+ }
51
+
52
+ const key = computePathKey(matched.seed, targetPath);
53
+ return { path: targetPath, key };
54
+ },
55
+ );
56
+
57
+ // GET /insider-key - Generate insider key (requires raw API key seed in header)
58
+ fastify.get('/insider-key', async (request, reply) => {
59
+ const provided = request.headers['x-api-key'] as string;
60
+ const config = getConfig();
61
+
62
+ if (!provided) {
63
+ return reply.code(401).send({ error: 'X-API-Key header required' });
64
+ }
65
+
66
+ const matched = config.resolvedKeys.find((rk) =>
67
+ timingSafeEqual(provided, rk.seed),
68
+ );
69
+ if (!matched) {
70
+ return reply.code(401).send({ error: 'Invalid API key' });
71
+ }
72
+
73
+ const key = computeInsiderKey(matched.seed);
74
+ return { key };
75
+ });
76
+
77
+ // POST /rotate-key - Rotate key (insider key or session cookie)
78
+ fastify.post<{ Querystring: { key?: string } }>(
79
+ '/rotate-key',
80
+ async (request, reply) => {
81
+ const provided = request.query.key;
82
+ const config = getConfig();
83
+
84
+ if (provided) {
85
+ // Try insider key auth
86
+ const insiderResult = resolveInsiderKeyAuth(config, provided);
87
+ if (insiderResult.valid && insiderResult.email) {
88
+ // Insider key rotation
89
+ return rotateInsiderSeed(insiderResult.email, config);
90
+ }
91
+
92
+ // Machine key rotation is not supported with TS config
93
+ const matched = config.resolvedKeys.find((rk) =>
94
+ timingSafeEqual(provided, computeInsiderKey(rk.seed)),
95
+ );
96
+ if (matched) {
97
+ return reply.code(501).send({
98
+ error:
99
+ 'Machine key rotation is not supported with TypeScript config. ' +
100
+ 'Update the key manually in jeeves.config.ts and restart the server.',
101
+ });
102
+ }
103
+ }
104
+
105
+ // Try session-based auth
106
+ const sessionResult = resolveSessionAuth(config, request);
107
+ if (sessionResult.valid && sessionResult.email) {
108
+ return rotateInsiderSeed(sessionResult.email, config);
109
+ }
110
+
111
+ return reply.code(401).send({ error: 'Invalid insider key' });
112
+ },
113
+ );
114
+
115
+ // GET /share - Generate outsider link (insider key or session cookie)
116
+ fastify.get<{ Querystring: { key?: string; path?: string; exp?: string } }>(
117
+ '/share',
118
+ async (request, reply) => {
119
+ const config = getConfig();
120
+ const targetPath = request.query.path;
121
+ if (!targetPath) {
122
+ return reply.code(400).send({ error: 'path query param required' });
123
+ }
124
+
125
+ // Try provided key
126
+ if (request.query.key) {
127
+ const insiderResult = resolveInsiderKeyAuth(config, request.query.key);
128
+ if (insiderResult.valid && insiderResult.seed) {
129
+ return buildShareResponse(
130
+ insiderResult.seed,
131
+ targetPath,
132
+ request.query.exp,
133
+ );
134
+ }
135
+ }
136
+
137
+ // Try session cookie
138
+ const sessionResult = resolveSessionAuth(config, request);
139
+ if (sessionResult.valid && sessionResult.seed) {
140
+ return buildShareResponse(
141
+ sessionResult.seed,
142
+ targetPath,
143
+ request.query.exp,
144
+ );
145
+ }
146
+
147
+ return reply.code(401).send({ error: 'Invalid insider key' });
148
+ },
149
+ );
150
+ };
151
+
152
+ function rotateInsiderSeed(
153
+ email: string,
154
+ config: ReturnType<typeof getConfig>,
155
+ ) {
156
+ const insider = findInsider(config.resolvedInsiders, email);
157
+ if (!insider?.seed) return { ok: false, error: 'Insider not found' };
158
+
159
+ const rotatedSeed = crypto.randomBytes(32).toString('hex');
160
+ const timestamp = new Date().toISOString();
161
+
162
+ setInsiderKey(insider.email, rotatedSeed, timestamp);
163
+ appendEvent({
164
+ kind: 'insider_key_rotated',
165
+ email: insider.email,
166
+ at: timestamp,
167
+ });
168
+ setKeyRotationTimestamp(timestamp);
169
+ resetConfig();
170
+
171
+ return { ok: true, keyName: insider.email };
172
+ }
173
+
174
+ function buildShareResponse(seed: string, targetPath: string, expiry?: string) {
175
+ let outsiderKey: string;
176
+ let shareUrl: string;
177
+
178
+ if (expiry) {
179
+ outsiderKey = computeOutsiderKeyWithExpiry(seed, targetPath, expiry);
180
+ shareUrl = `/browse${targetPath}?key=${outsiderKey}&exp=${expiry}`;
181
+ } else {
182
+ outsiderKey = computePathKey(seed, targetPath);
183
+ shareUrl = `/browse${targetPath}?key=${outsiderKey}`;
184
+ }
185
+
186
+ return {
187
+ path: targetPath,
188
+ key: outsiderKey,
189
+ exp: expiry ?? null,
190
+ url: shareUrl,
191
+ };
192
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Legacy /path redirect — sends all /path/* requests to /browse/*
3
+ */
4
+
5
+ import type { FastifyPluginAsync } from 'fastify';
6
+
7
+ // eslint-disable-next-line @typescript-eslint/require-await
8
+ export const pathRoute: FastifyPluginAsync = async (fastify) => {
9
+ // Redirect /path (root) to /browse
10
+ fastify.get('/path', async (_request, reply) => {
11
+ return reply.redirect('/browse');
12
+ });
13
+
14
+ // Redirect /path/* to /browse/*
15
+ fastify.get<{ Params: { '*': string } }>(
16
+ '/path/*',
17
+ async (request, reply) => {
18
+ const reqPath = request.params['*'];
19
+ const url = new URL(request.url, 'http://localhost');
20
+ const query = url.search;
21
+ return reply.redirect(`/browse/${reqPath}${query}`);
22
+ },
23
+ );
24
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Static asset routes — serves locally-bundled libraries from node_modules.
3
+ * Eliminates CDN dependencies for Lucide, Panzoom, and highlight.js.
4
+ */
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import type { FastifyPluginAsync } from 'fastify';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ // eslint-disable-next-line @typescript-eslint/require-await
15
+ export const staticRoutes: FastifyPluginAsync = async (fastify) => {
16
+ // robots.txt — block all crawlers
17
+ fastify.get('/robots.txt', async (_request, reply) => {
18
+ reply.type('text/plain').send('User-agent: *\nDisallow: /\n');
19
+ });
20
+
21
+ // Favicon
22
+ fastify.get('/favicon.svg', async (_request, reply) => {
23
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="90">🎩</text></svg>`;
24
+ reply.type('image/svg+xml').send(svg);
25
+ });
26
+
27
+ // Lucide icons
28
+ fastify.get('/static/lucide.min.js', async (_request, reply) => {
29
+ const filePath = path.join(
30
+ __dirname,
31
+ '..',
32
+ 'node_modules',
33
+ 'lucide',
34
+ 'dist',
35
+ 'umd',
36
+ 'lucide.min.js',
37
+ );
38
+ return reply.type('application/javascript').send(fs.readFileSync(filePath));
39
+ });
40
+
41
+ // Panzoom
42
+ fastify.get('/static/panzoom.min.js', async (_request, reply) => {
43
+ const filePath = path.join(
44
+ __dirname,
45
+ '..',
46
+ 'node_modules',
47
+ '@panzoom',
48
+ 'panzoom',
49
+ 'dist',
50
+ 'panzoom.min.js',
51
+ );
52
+ return reply.type('application/javascript').send(fs.readFileSync(filePath));
53
+ });
54
+ };