@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.
- package/.env.local +13 -0
- package/.env.local.template +13 -0
- package/.tsbuildinfo +1 -0
- package/CHANGELOG.md +450 -0
- package/about.md +82 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +14 -0
- package/client/package-lock.json +5181 -0
- package/client/package.json +60 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.tsx +22 -0
- package/client/src/components/AccountMenu.tsx +167 -0
- package/client/src/components/ActionDropdown.tsx +120 -0
- package/client/src/components/CodeEditor.tsx +143 -0
- package/client/src/components/CodeViewer.tsx +113 -0
- package/client/src/components/ConfirmDialog.tsx +32 -0
- package/client/src/components/DirectoryRow.tsx +62 -0
- package/client/src/components/DirectoryTable.tsx +42 -0
- package/client/src/components/DownloadDropdown.tsx +116 -0
- package/client/src/components/DriveList.tsx +54 -0
- package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
- package/client/src/components/FileContentView.tsx +155 -0
- package/client/src/components/InlineSvgPanzoom.ts +60 -0
- package/client/src/components/LazyDiagram.ts +93 -0
- package/client/src/components/LinkDropdown.tsx +134 -0
- package/client/src/components/MarkdownView.tsx +115 -0
- package/client/src/components/MermaidViewer.tsx +21 -0
- package/client/src/components/PlantUmlViewer.tsx +21 -0
- package/client/src/components/SearchModal.tsx +424 -0
- package/client/src/components/SvgViewer.tsx +107 -0
- package/client/src/components/TabBar.tsx +96 -0
- package/client/src/components/layout/Header.tsx +270 -0
- package/client/src/components/panzoom.ts +203 -0
- package/client/src/components/renderableUtils.ts +15 -0
- package/client/src/components/runner/JobTable.tsx +153 -0
- package/client/src/components/runner/RunHistory.tsx +140 -0
- package/client/src/components/runner/StatsBar.tsx +43 -0
- package/client/src/components/runner/StatusPill.tsx +27 -0
- package/client/src/components/runner/jobTableUtils.ts +65 -0
- package/client/src/components/scrollUtils.ts +39 -0
- package/client/src/components/ui/alert-dialog.tsx +107 -0
- package/client/src/components/ui/button.tsx +40 -0
- package/client/src/components/ui/dropdown-menu.tsx +79 -0
- package/client/src/components/ui/input.tsx +26 -0
- package/client/src/components/useActionState.ts +43 -0
- package/client/src/hooks/useFileBrowser.ts +102 -0
- package/client/src/hooks/useFileData.ts +78 -0
- package/client/src/hooks/useScrollAnchor.ts +70 -0
- package/client/src/hooks/useShareSettings.ts +22 -0
- package/client/src/hooks/useTopBar.ts +27 -0
- package/client/src/index.css +281 -0
- package/client/src/lib/AuthContext.ts +27 -0
- package/client/src/lib/api.ts +239 -0
- package/client/src/lib/auth.tsx +50 -0
- package/client/src/lib/codeBlockCm6.ts +129 -0
- package/client/src/lib/codeBlockCopy.ts +43 -0
- package/client/src/lib/codemirror.ts +77 -0
- package/client/src/lib/runner-api.ts +172 -0
- package/client/src/lib/svg.ts +50 -0
- package/client/src/lib/theme.ts +34 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/main.tsx +11 -0
- package/client/src/pages/FileBrowser.tsx +135 -0
- package/client/src/pages/Home.tsx +46 -0
- package/client/src/pages/Runner.tsx +151 -0
- package/client/src/pages/RunnerJob.tsx +170 -0
- package/client/tsconfig.app.json +32 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +35 -0
- package/content/privacy.md +61 -0
- package/content/terms.md +41 -0
- package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
- package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
- package/dist/client/assets/index--MBieNJA.js +1 -0
- package/dist/client/assets/index-BENeXQI_.js +1 -0
- package/dist/client/assets/index-BbBpoOxz.js +1 -0
- package/dist/client/assets/index-BdV9g5AM.js +6 -0
- package/dist/client/assets/index-BjAilRri.js +2 -0
- package/dist/client/assets/index-BqbhWo2I.js +3 -0
- package/dist/client/assets/index-CVbycZ0H.js +1 -0
- package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
- package/dist/client/assets/index-D8KZVveX.js +1 -0
- package/dist/client/assets/index-DC4HMHxY.js +13 -0
- package/dist/client/assets/index-DbMebkkd.css +1 -0
- package/dist/client/assets/index-DcY2RXqX.js +1 -0
- package/dist/client/assets/index-Duy-tZYV.js +1 -0
- package/dist/client/assets/index-Dw7rDFmE.js +7 -0
- package/dist/client/assets/index-FlCUvrjv.js +2 -0
- package/dist/client/assets/index-K6OVmfhg.js +1 -0
- package/dist/client/assets/index-LjwgzZ7F.js +62 -0
- package/dist/client/assets/index-MLwyFRN0.js +1 -0
- package/dist/client/assets/index-OpqBpSjn.js +1 -0
- package/dist/client/assets/index-SsHei0HE.js +1 -0
- package/dist/client/assets/index-uQa2yckk.js +1 -0
- package/dist/client/assets/index-udkXoIER.js +1 -0
- package/dist/client/index.html +15 -0
- package/dist/client/vite.svg +1 -0
- package/dist/src/auth/google.js +57 -0
- package/dist/src/auth/keys.js +185 -0
- package/dist/src/auth/resolve.js +102 -0
- package/dist/src/auth/session.js +57 -0
- package/dist/src/cli/commands/config.js +100 -0
- package/dist/src/cli/commands/config.test.js +84 -0
- package/dist/src/cli/commands/service.js +93 -0
- package/dist/src/cli/commands/start.js +24 -0
- package/dist/src/cli/index.js +20 -0
- package/dist/src/config/index.js +90 -0
- package/dist/src/config/loadConfig.test.js +127 -0
- package/dist/src/config/resolve.js +134 -0
- package/dist/src/config/resolve.test.js +148 -0
- package/dist/src/config/schema.js +159 -0
- package/dist/src/config/substituteEnvVars.js +45 -0
- package/dist/src/config/substituteEnvVars.test.js +51 -0
- package/dist/src/config/types.js +5 -0
- package/dist/src/routes/api/auth-status.js +56 -0
- package/dist/src/routes/api/diagrams.js +35 -0
- package/dist/src/routes/api/directory.js +93 -0
- package/dist/src/routes/api/drives.js +15 -0
- package/dist/src/routes/api/export.js +218 -0
- package/dist/src/routes/api/fileContent.js +286 -0
- package/dist/src/routes/api/index.js +33 -0
- package/dist/src/routes/api/linkInfo.js +71 -0
- package/dist/src/routes/api/linkInfo.test.js +104 -0
- package/dist/src/routes/api/middleware.js +117 -0
- package/dist/src/routes/api/raw.js +38 -0
- package/dist/src/routes/api/runner.js +59 -0
- package/dist/src/routes/api/search.js +236 -0
- package/dist/src/routes/api/sharing.js +203 -0
- package/dist/src/routes/api/status.js +68 -0
- package/dist/src/routes/api/status.test.js +62 -0
- package/dist/src/routes/auth.js +99 -0
- package/dist/src/routes/event.js +77 -0
- package/dist/src/routes/event.test.js +206 -0
- package/dist/src/routes/health.js +10 -0
- package/dist/src/routes/keys.js +129 -0
- package/dist/src/routes/path/index.js +17 -0
- package/dist/src/routes/static.js +30 -0
- package/dist/src/server.js +90 -0
- package/dist/src/services/deepShareLinks.js +163 -0
- package/dist/src/services/diagramCache.js +104 -0
- package/dist/src/services/embeddedDiagrams.js +136 -0
- package/dist/src/services/eventLog.js +55 -0
- package/dist/src/services/eventLog.test.js +113 -0
- package/dist/src/services/eventQueue.js +154 -0
- package/dist/src/services/eventQueue.test.js +104 -0
- package/dist/src/services/export.js +220 -0
- package/dist/src/services/exportCache.js +196 -0
- package/dist/src/services/markdown.js +147 -0
- package/dist/src/services/mermaid.js +97 -0
- package/dist/src/services/plantuml.js +145 -0
- package/dist/src/services/puppeteer.js +156 -0
- package/dist/src/util/breadcrumbs.js +22 -0
- package/dist/src/util/crypto.js +56 -0
- package/dist/src/util/crypto.test.js +99 -0
- package/dist/src/util/fileDetection.js +66 -0
- package/dist/src/util/fileDetection.test.js +89 -0
- package/dist/src/util/formatters.js +43 -0
- package/dist/src/util/formatters.test.js +83 -0
- package/dist/src/util/packageVersion.js +25 -0
- package/dist/src/util/platform.js +148 -0
- package/dist/src/util/state.js +46 -0
- package/dist/vitest.config.js +12 -0
- package/favicon.svg +3 -0
- package/guides/access-decision-flow.mmd +24 -0
- package/guides/access-decision-flow.svg +1 -0
- package/guides/api-integration.md +236 -0
- package/guides/deployment.md +287 -0
- package/guides/event-gateway.md +204 -0
- package/guides/event-gateway.mmd +17 -0
- package/guides/event-gateway.svg +1 -0
- package/guides/exports.md +239 -0
- package/guides/setup.md +313 -0
- package/guides/sharing.md +204 -0
- package/jeeves-server.config.template.json +25 -0
- package/package.json +124 -0
- package/scripts/download-plantuml.js +70 -0
- package/src/auth/google.ts +93 -0
- package/src/auth/keys.ts +252 -0
- package/src/auth/resolve.ts +157 -0
- package/src/auth/session.ts +77 -0
- package/src/cli/commands/config.test.ts +107 -0
- package/src/cli/commands/config.ts +113 -0
- package/src/cli/commands/service.ts +129 -0
- package/src/cli/commands/start.ts +27 -0
- package/src/cli/index.ts +25 -0
- package/src/config/index.ts +113 -0
- package/src/config/loadConfig.test.ts +155 -0
- package/src/config/resolve.test.ts +192 -0
- package/src/config/resolve.ts +173 -0
- package/src/config/schema.ts +179 -0
- package/src/config/substituteEnvVars.test.ts +64 -0
- package/src/config/substituteEnvVars.ts +52 -0
- package/src/config/types.ts +129 -0
- package/src/routes/api/auth-status.ts +85 -0
- package/src/routes/api/diagrams.ts +53 -0
- package/src/routes/api/directory.ts +123 -0
- package/src/routes/api/drives.ts +23 -0
- package/src/routes/api/export.ts +314 -0
- package/src/routes/api/fileContent.ts +414 -0
- package/src/routes/api/index.ts +37 -0
- package/src/routes/api/linkInfo.test.ts +132 -0
- package/src/routes/api/linkInfo.ts +83 -0
- package/src/routes/api/middleware.ts +156 -0
- package/src/routes/api/raw.ts +54 -0
- package/src/routes/api/runner.ts +107 -0
- package/src/routes/api/search.ts +321 -0
- package/src/routes/api/sharing.ts +259 -0
- package/src/routes/api/status.test.ts +72 -0
- package/src/routes/api/status.ts +82 -0
- package/src/routes/auth.ts +143 -0
- package/src/routes/event.test.ts +248 -0
- package/src/routes/event.ts +109 -0
- package/src/routes/health.ts +13 -0
- package/src/routes/keys.ts +192 -0
- package/src/routes/path/index.ts +24 -0
- package/src/routes/static.ts +54 -0
- package/src/server.ts +104 -0
- package/src/services/deepShareLinks.ts +203 -0
- package/src/services/diagramCache.ts +128 -0
- package/src/services/embeddedDiagrams.ts +168 -0
- package/src/services/eventLog.test.ts +144 -0
- package/src/services/eventLog.ts +68 -0
- package/src/services/eventQueue.test.ts +127 -0
- package/src/services/eventQueue.ts +196 -0
- package/src/services/export.ts +267 -0
- package/src/services/exportCache.ts +216 -0
- package/src/services/markdown.ts +189 -0
- package/src/services/mermaid.ts +113 -0
- package/src/services/plantuml.ts +172 -0
- package/src/services/puppeteer.ts +188 -0
- package/src/types/fastify.d.ts +13 -0
- package/src/types/jsonmap.d.ts +10 -0
- package/src/types/plantuml-encoder.d.ts +4 -0
- package/src/util/breadcrumbs.ts +33 -0
- package/src/util/crypto.test.ts +132 -0
- package/src/util/crypto.ts +79 -0
- package/src/util/fileDetection.test.ts +115 -0
- package/src/util/fileDetection.ts +70 -0
- package/src/util/formatters.test.ts +105 -0
- package/src/util/formatters.ts +44 -0
- package/src/util/packageVersion.ts +30 -0
- package/src/util/platform.ts +178 -0
- package/src/util/state.ts +55 -0
- package/test-docs/diagram-retry-test.md +18 -0
- package/test-docs/embedded-diagrams.md +52 -0
- package/test-docs/lazy-diagrams-test.md +333 -0
- package/test-docs/page-a.md +7 -0
- package/test-docs/page-b.md +7 -0
- package/test-docs/page-c.md +7 -0
- package/test-docs/sub/page-d.md +7 -0
- package/test-docs/test-diagram.puml +13 -0
- package/test-docs/validate-deep-share.js +318 -0
- package/tsconfig.json +37 -0
- package/tsdoc.json +13 -0
- package/vendor/.plantuml-version +1 -0
- package/vendor/plantuml.jar +0 -0
- package/vitest.config.js +12 -0
- 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
|
+
};
|