@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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API authentication middleware (preHandler hook).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FastifyInstance } from 'fastify';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
resolveInsiderKeyAuth,
|
|
9
|
+
resolveKeyAuth,
|
|
10
|
+
resolveSessionAuth,
|
|
11
|
+
} from '../../auth/resolve.js';
|
|
12
|
+
import { getConfig } from '../../config/index.js';
|
|
13
|
+
import { decodeStack } from '../../services/deepShareLinks.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Add the API auth preHandler hook directly to a Fastify instance.
|
|
17
|
+
* Must be called on the parent context (not via register()) so the hook
|
|
18
|
+
* applies to all sibling and child routes.
|
|
19
|
+
*/
|
|
20
|
+
export function addAuthMiddleware(fastify: FastifyInstance): void {
|
|
21
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
22
|
+
if (!request.url.startsWith('/api')) return;
|
|
23
|
+
if (request.url.startsWith('/api/readme-link')) return;
|
|
24
|
+
if (request.url.startsWith('/api/content-link/')) return;
|
|
25
|
+
if (request.url.startsWith('/api/auth/status')) return;
|
|
26
|
+
if (request.url.startsWith('/api/diagram/')) return;
|
|
27
|
+
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
|
|
30
|
+
// Utility endpoints handle their own scope checking
|
|
31
|
+
if (request.url.startsWith('/api/util/')) {
|
|
32
|
+
const query = request.query as { key?: string; exp?: string };
|
|
33
|
+
|
|
34
|
+
// Try key-based auth
|
|
35
|
+
if (query.key) {
|
|
36
|
+
const keyResult = resolveKeyAuth(config, '/', query.key, query.exp);
|
|
37
|
+
if (keyResult.valid && keyResult.mode === 'insider') {
|
|
38
|
+
request.accessMode = 'insider';
|
|
39
|
+
request.authSeed = keyResult.seed;
|
|
40
|
+
request.insiderScopes = keyResult.scopes ?? null;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Try as a direct insider key
|
|
45
|
+
const insiderResult = resolveInsiderKeyAuth(config, query.key);
|
|
46
|
+
if (insiderResult.valid) {
|
|
47
|
+
request.accessMode = 'insider';
|
|
48
|
+
request.authSeed = insiderResult.seed;
|
|
49
|
+
request.insiderScopes = insiderResult.scopes ?? null;
|
|
50
|
+
request.insiderEmail = insiderResult.email;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try session cookie
|
|
56
|
+
const sessionResult = resolveSessionAuth(config, request);
|
|
57
|
+
if (sessionResult.valid) {
|
|
58
|
+
request.accessMode = 'insider';
|
|
59
|
+
request.authSeed = sessionResult.seed;
|
|
60
|
+
request.insiderScopes = sessionResult.scopes ?? null;
|
|
61
|
+
request.insiderEmail = sessionResult.email;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
reply
|
|
66
|
+
.code(401)
|
|
67
|
+
.send({ error: 'Insider auth required for utility endpoints' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// General API auth
|
|
72
|
+
const query = request.query as {
|
|
73
|
+
key?: string;
|
|
74
|
+
exp?: string;
|
|
75
|
+
d?: string;
|
|
76
|
+
dirs?: string;
|
|
77
|
+
s?: string;
|
|
78
|
+
};
|
|
79
|
+
const deepParams =
|
|
80
|
+
query.d !== undefined && query.s !== undefined
|
|
81
|
+
? { d: query.d, dirs: query.dirs ?? '0', s: query.s }
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
const urlPath = request.url
|
|
85
|
+
.split('?')[0]
|
|
86
|
+
.replace('/api/path', '')
|
|
87
|
+
.replace('/api/drives', '/')
|
|
88
|
+
.replace('/api/file', '')
|
|
89
|
+
.replace('/api/raw', '')
|
|
90
|
+
.replace('/api/export-cache', '')
|
|
91
|
+
.replace('/api/export', '');
|
|
92
|
+
|
|
93
|
+
// Try key-based auth
|
|
94
|
+
let authResult = resolveKeyAuth(
|
|
95
|
+
config,
|
|
96
|
+
urlPath || '/',
|
|
97
|
+
query.key,
|
|
98
|
+
query.exp,
|
|
99
|
+
deepParams,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Retry with dirs fallback (directory shares)
|
|
103
|
+
if (
|
|
104
|
+
!authResult.valid &&
|
|
105
|
+
deepParams &&
|
|
106
|
+
deepParams.dirs === '1' &&
|
|
107
|
+
query.key
|
|
108
|
+
) {
|
|
109
|
+
const stack = decodeStack(deepParams.s);
|
|
110
|
+
const lastStackEntry = stack[stack.length - 1];
|
|
111
|
+
if (lastStackEntry && lastStackEntry !== urlPath) {
|
|
112
|
+
authResult = resolveKeyAuth(
|
|
113
|
+
config,
|
|
114
|
+
lastStackEntry,
|
|
115
|
+
query.key,
|
|
116
|
+
query.exp,
|
|
117
|
+
deepParams,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Try session cookie (always check — insiders visiting outsider links
|
|
123
|
+
// should be upgraded to insider access)
|
|
124
|
+
const sessionResult = resolveSessionAuth(config, request);
|
|
125
|
+
|
|
126
|
+
if (authResult.valid && sessionResult.valid) {
|
|
127
|
+
// Both key and session are valid — prefer insider session
|
|
128
|
+
request.accessMode = 'insider';
|
|
129
|
+
request.authSeed = sessionResult.seed;
|
|
130
|
+
request.insiderEmail = sessionResult.email;
|
|
131
|
+
request.insiderScopes = sessionResult.scopes ?? null;
|
|
132
|
+
request.keyAge = sessionResult.keyAge;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (authResult.valid) {
|
|
137
|
+
request.accessMode = authResult.mode;
|
|
138
|
+
request.authSeed = authResult.seed;
|
|
139
|
+
request.deepShareParams = authResult.deepShareParams;
|
|
140
|
+
request.authMatchedPath = authResult.matchedPath;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (sessionResult.valid) {
|
|
145
|
+
request.accessMode = 'insider';
|
|
146
|
+
request.authSeed = sessionResult.seed;
|
|
147
|
+
request.insiderEmail = sessionResult.email;
|
|
148
|
+
request.insiderScopes = sessionResult.scopes ?? null;
|
|
149
|
+
request.keyAge = sessionResult.keyAge;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
reply.code(401).send({ error: 'Unauthorized' });
|
|
154
|
+
return;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw file serving API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: GET /api/raw/*
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
11
|
+
|
|
12
|
+
import { getConfig } from '../../config/index.js';
|
|
13
|
+
import { getContentType, isInlineType } from '../../util/fileDetection.js';
|
|
14
|
+
import { getRoots, urlPathToFs } from '../../util/platform.js';
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
17
|
+
export const rawRoutes: FastifyPluginAsync = async (fastify) => {
|
|
18
|
+
const roots = getRoots(getConfig().roots);
|
|
19
|
+
|
|
20
|
+
fastify.get<{ Params: { '*': string } }>(
|
|
21
|
+
'/api/raw/*',
|
|
22
|
+
async (request, reply) => {
|
|
23
|
+
const reqPath = request.params['*'];
|
|
24
|
+
if (!reqPath) return reply.code(400).send({ error: 'Path required' });
|
|
25
|
+
|
|
26
|
+
const rawFsPath = urlPathToFs(reqPath, roots);
|
|
27
|
+
if (!rawFsPath) return reply.code(404).send({ error: 'Invalid path' });
|
|
28
|
+
const resolved = path.resolve(rawFsPath);
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(resolved))
|
|
31
|
+
return reply.code(404).send({ error: 'Not found', path: resolved });
|
|
32
|
+
|
|
33
|
+
const stats = fs.statSync(resolved);
|
|
34
|
+
if (stats.isDirectory()) {
|
|
35
|
+
return reply.code(400).send({
|
|
36
|
+
error: 'Cannot serve directory as raw — use /api/export for ZIP',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
41
|
+
const contentType = getContentType(ext);
|
|
42
|
+
reply.header('Content-Type', contentType);
|
|
43
|
+
|
|
44
|
+
if (!isInlineType(contentType)) {
|
|
45
|
+
reply.header(
|
|
46
|
+
'Content-Disposition',
|
|
47
|
+
`attachment; filename="${path.basename(resolved)}"`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return reply.send(fs.readFileSync(resolved));
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runner proxy routes — proxies requests to the local jeeves-runner API.
|
|
3
|
+
*
|
|
4
|
+
* All routes require insider auth. The runner only listens on localhost,
|
|
5
|
+
* so jeeves-server acts as an authenticated gateway.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FastifyPluginAsync, FastifyReply } from 'fastify';
|
|
9
|
+
|
|
10
|
+
import { getConfig } from '../../config/index.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RUNNER_URL = 'http://127.0.0.1:3100';
|
|
13
|
+
|
|
14
|
+
function getRunnerUrl(): string {
|
|
15
|
+
return getConfig().runnerUrl ?? DEFAULT_RUNNER_URL;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Proxy a request to the runner and send the response via reply. */
|
|
19
|
+
async function proxyToRunner(
|
|
20
|
+
reply: FastifyReply,
|
|
21
|
+
path: string,
|
|
22
|
+
method: 'GET' | 'POST' = 'GET',
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${getRunnerUrl()}${path}`, { method });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
void reply
|
|
28
|
+
.code(res.status)
|
|
29
|
+
.header(
|
|
30
|
+
'content-type',
|
|
31
|
+
res.headers.get('content-type') ?? 'application/json',
|
|
32
|
+
)
|
|
33
|
+
.send(text);
|
|
34
|
+
} catch {
|
|
35
|
+
void reply.code(502).send(JSON.stringify({ error: 'Runner unreachable' }));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
40
|
+
export const runnerRoutes: FastifyPluginAsync = async (fastify) => {
|
|
41
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
42
|
+
if (request.accessMode !== 'insider') {
|
|
43
|
+
return reply.code(403).send({ error: 'Insider access required' });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
fastify.get('/api/runner/health', async (_req, reply) => {
|
|
48
|
+
await proxyToRunner(reply, '/health');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
fastify.get('/api/runner/jobs', async (_req, reply) => {
|
|
52
|
+
await proxyToRunner(reply, '/jobs');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fastify.get<{ Params: { id: string } }>(
|
|
56
|
+
'/api/runner/jobs/:id',
|
|
57
|
+
async (req, reply) => {
|
|
58
|
+
await proxyToRunner(reply, `/jobs/${encodeURIComponent(req.params.id)}`);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
fastify.get<{ Params: { id: string }; Querystring: { limit?: string } }>(
|
|
63
|
+
'/api/runner/jobs/:id/runs',
|
|
64
|
+
async (req, reply) => {
|
|
65
|
+
const limit = req.query.limit ?? '20';
|
|
66
|
+
const id = encodeURIComponent(req.params.id);
|
|
67
|
+
await proxyToRunner(reply, `/jobs/${id}/runs?limit=${limit}`);
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
fastify.post<{ Params: { id: string } }>(
|
|
72
|
+
'/api/runner/jobs/:id/run',
|
|
73
|
+
async (req, reply) => {
|
|
74
|
+
await proxyToRunner(
|
|
75
|
+
reply,
|
|
76
|
+
`/jobs/${encodeURIComponent(req.params.id)}/run`,
|
|
77
|
+
'POST',
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
fastify.post<{ Params: { id: string } }>(
|
|
83
|
+
'/api/runner/jobs/:id/enable',
|
|
84
|
+
async (req, reply) => {
|
|
85
|
+
await proxyToRunner(
|
|
86
|
+
reply,
|
|
87
|
+
`/jobs/${encodeURIComponent(req.params.id)}/enable`,
|
|
88
|
+
'POST',
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
fastify.post<{ Params: { id: string } }>(
|
|
94
|
+
'/api/runner/jobs/:id/disable',
|
|
95
|
+
async (req, reply) => {
|
|
96
|
+
await proxyToRunner(
|
|
97
|
+
reply,
|
|
98
|
+
`/jobs/${encodeURIComponent(req.params.id)}/disable`,
|
|
99
|
+
'POST',
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
fastify.get('/api/runner/stats', async (_req, reply) => {
|
|
105
|
+
await proxyToRunner(reply, '/stats');
|
|
106
|
+
});
|
|
107
|
+
};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search API route — proxies to jeeves-watcher for semantic search.
|
|
3
|
+
*
|
|
4
|
+
* Handles: POST /api/search
|
|
5
|
+
* Insider-only. Results filtered by insider's scope.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { stat } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
12
|
+
import picomatch from 'picomatch';
|
|
13
|
+
|
|
14
|
+
import { getConfig } from '../../config/index.js';
|
|
15
|
+
import type { NormalizedScopes } from '../../config/types.js';
|
|
16
|
+
|
|
17
|
+
/** Check if a path passes the insider's scope rules. */
|
|
18
|
+
function pathAllowedByScope(
|
|
19
|
+
urlPath: string,
|
|
20
|
+
scopes: NormalizedScopes | null,
|
|
21
|
+
): boolean {
|
|
22
|
+
if (!scopes) return true; // null = unrestricted
|
|
23
|
+
const normalized = urlPath.toLowerCase().replace(/\/+$/, '');
|
|
24
|
+
const allowMatch = picomatch(
|
|
25
|
+
scopes.allow.map((p) => p.toLowerCase().replace(/\/+$/, '')),
|
|
26
|
+
);
|
|
27
|
+
if (!allowMatch(normalized)) return false;
|
|
28
|
+
if (scopes.deny.length > 0) {
|
|
29
|
+
const denyMatch = picomatch(
|
|
30
|
+
scopes.deny.map((p) => p.toLowerCase().replace(/\/+$/, '')),
|
|
31
|
+
);
|
|
32
|
+
if (denyMatch(normalized)) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface WatcherResult {
|
|
38
|
+
id: string;
|
|
39
|
+
score: number;
|
|
40
|
+
payload: {
|
|
41
|
+
file_path?: string;
|
|
42
|
+
chunk_text?: string;
|
|
43
|
+
chunk_index?: number;
|
|
44
|
+
total_chunks?: number;
|
|
45
|
+
domains?: string[];
|
|
46
|
+
title?: string;
|
|
47
|
+
author?: string;
|
|
48
|
+
participants?: string;
|
|
49
|
+
content_hash?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface GroupedResult {
|
|
55
|
+
filePath: string;
|
|
56
|
+
browsePath: string;
|
|
57
|
+
fileName: string;
|
|
58
|
+
bestScore: number;
|
|
59
|
+
mtime?: string;
|
|
60
|
+
domains?: string[];
|
|
61
|
+
title?: string;
|
|
62
|
+
author?: string;
|
|
63
|
+
participants?: string;
|
|
64
|
+
chunks: Array<{
|
|
65
|
+
text: string;
|
|
66
|
+
index: number;
|
|
67
|
+
score: number;
|
|
68
|
+
}>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a browse path back to a filesystem path using roots config.
|
|
73
|
+
* Inverse of fsPathToBrowsePath.
|
|
74
|
+
*/
|
|
75
|
+
function browsePathToFsPath(
|
|
76
|
+
browsePath: string,
|
|
77
|
+
roots: Record<string, string>,
|
|
78
|
+
): string | null {
|
|
79
|
+
const parts = browsePath.split('/');
|
|
80
|
+
const label = parts[0];
|
|
81
|
+
const rest = parts.slice(1).join('/');
|
|
82
|
+
|
|
83
|
+
// Check if label matches a root
|
|
84
|
+
if (roots[label]) {
|
|
85
|
+
return path.join(roots[label], rest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Windows drive letter: j/foo/bar → J:\foo\bar
|
|
89
|
+
if (/^[a-zA-Z]$/.test(label)) {
|
|
90
|
+
return `${label.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Convert an absolute filesystem path to a browse URL path.
|
|
98
|
+
* Maps drive letters and root mounts back to the URL scheme.
|
|
99
|
+
*/
|
|
100
|
+
function fsPathToBrowsePath(
|
|
101
|
+
fsPath: string,
|
|
102
|
+
roots: Record<string, string>,
|
|
103
|
+
): string | null {
|
|
104
|
+
const normalized = fsPath.replace(/\\/g, '/');
|
|
105
|
+
|
|
106
|
+
// Windows drive letter: j:/foo/bar → j/foo/bar
|
|
107
|
+
const driveMatch = normalized.match(/^([a-zA-Z]):\/(.*)$/);
|
|
108
|
+
if (driveMatch) {
|
|
109
|
+
return `${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Linux roots: find matching root prefix
|
|
113
|
+
for (const [label, rootPath] of Object.entries(roots)) {
|
|
114
|
+
const normalizedRoot = rootPath.replace(/\\/g, '/').replace(/\/$/, '');
|
|
115
|
+
if (normalized.startsWith(normalizedRoot + '/')) {
|
|
116
|
+
const relative = normalized.slice(normalizedRoot.length + 1);
|
|
117
|
+
return `${label}/${relative}`;
|
|
118
|
+
}
|
|
119
|
+
if (normalized === normalizedRoot) {
|
|
120
|
+
return label;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
128
|
+
export const searchRoutes: FastifyPluginAsync = async (fastify) => {
|
|
129
|
+
fastify.post<{
|
|
130
|
+
Body: {
|
|
131
|
+
query: string;
|
|
132
|
+
limit?: number;
|
|
133
|
+
filter?: Record<string, unknown>;
|
|
134
|
+
};
|
|
135
|
+
}>('/api/search', async (request, reply) => {
|
|
136
|
+
// Insider-only
|
|
137
|
+
if (request.accessMode !== 'insider') {
|
|
138
|
+
return reply.code(403).send({ error: 'Insider access required' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const config = getConfig();
|
|
142
|
+
if (!config.watcherUrl) {
|
|
143
|
+
return reply.code(501).send({ error: 'Search not configured' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { query, limit = 20, filter } = request.body;
|
|
147
|
+
if (!query || typeof query !== 'string') {
|
|
148
|
+
return reply.code(400).send({ error: 'query is required' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const insiderScopes = request.insiderScopes;
|
|
152
|
+
const roots = config.roots ?? {};
|
|
153
|
+
|
|
154
|
+
// Over-fetch to account for scope filtering
|
|
155
|
+
const fetchLimit = Math.min(limit * 5, 200);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const watcherRes = await fetch(`${config.watcherUrl}/search`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ query, limit: fetchLimit, filter }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!watcherRes.ok) {
|
|
165
|
+
const msg = await watcherRes.text().catch(() => '');
|
|
166
|
+
return await reply
|
|
167
|
+
.code(502)
|
|
168
|
+
.send({ error: `Watcher search failed: ${msg}` });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rawResults = (await watcherRes.json()) as WatcherResult[];
|
|
172
|
+
|
|
173
|
+
// Filter by insider scope and map paths
|
|
174
|
+
const permitted: Array<WatcherResult & { browsePath: string }> = [];
|
|
175
|
+
for (const r of rawResults) {
|
|
176
|
+
const fp = r.payload.file_path;
|
|
177
|
+
if (!fp) continue;
|
|
178
|
+
|
|
179
|
+
const browsePath = fsPathToBrowsePath(fp, roots);
|
|
180
|
+
if (!browsePath) continue;
|
|
181
|
+
|
|
182
|
+
// Check insider scope
|
|
183
|
+
const urlPath = `/${browsePath}`;
|
|
184
|
+
if (!pathAllowedByScope(urlPath, insiderScopes ?? null)) continue;
|
|
185
|
+
|
|
186
|
+
permitted.push({ ...r, browsePath });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Group by file path, take top `limit` files
|
|
190
|
+
const fileMap = new Map<string, GroupedResult>();
|
|
191
|
+
for (const r of permitted) {
|
|
192
|
+
const key = r.browsePath;
|
|
193
|
+
let group = fileMap.get(key);
|
|
194
|
+
if (!group) {
|
|
195
|
+
const parts = key.split('/');
|
|
196
|
+
group = {
|
|
197
|
+
filePath: r.payload.file_path ?? key,
|
|
198
|
+
browsePath: key,
|
|
199
|
+
fileName: parts[parts.length - 1],
|
|
200
|
+
bestScore: r.score,
|
|
201
|
+
domains: Array.isArray(r.payload.domains)
|
|
202
|
+
? r.payload.domains
|
|
203
|
+
: r.payload.domain
|
|
204
|
+
? [r.payload.domain as string]
|
|
205
|
+
: [],
|
|
206
|
+
title: r.payload.title,
|
|
207
|
+
author: r.payload.author,
|
|
208
|
+
participants: r.payload.participants,
|
|
209
|
+
chunks: [],
|
|
210
|
+
};
|
|
211
|
+
fileMap.set(key, group);
|
|
212
|
+
}
|
|
213
|
+
if (r.score > group.bestScore) group.bestScore = r.score;
|
|
214
|
+
group.chunks.push({
|
|
215
|
+
text: r.payload.chunk_text ?? '',
|
|
216
|
+
index: r.payload.chunk_index ?? 0,
|
|
217
|
+
score: r.score,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Sort by best score, limit
|
|
222
|
+
const grouped = [...fileMap.values()]
|
|
223
|
+
.sort((a, b) => b.bestScore - a.bestScore)
|
|
224
|
+
.slice(0, limit);
|
|
225
|
+
|
|
226
|
+
// Sort chunks within each group by index, and fetch mtime
|
|
227
|
+
await Promise.all(
|
|
228
|
+
grouped.map(async (g) => {
|
|
229
|
+
g.chunks.sort((a, b) => a.index - b.index);
|
|
230
|
+
const fsPath = browsePathToFsPath(g.browsePath, roots);
|
|
231
|
+
if (fsPath) {
|
|
232
|
+
try {
|
|
233
|
+
const s = await stat(fsPath);
|
|
234
|
+
g.mtime = s.mtime.toISOString();
|
|
235
|
+
} catch {
|
|
236
|
+
/* file may not be accessible */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Extract distinct metadata values for filter chips
|
|
243
|
+
const metadata = {
|
|
244
|
+
domains: [
|
|
245
|
+
...new Set(grouped.flatMap((g) => g.domains || []).filter(Boolean)),
|
|
246
|
+
],
|
|
247
|
+
authors: [...new Set(grouped.map((g) => g.author).filter(Boolean))],
|
|
248
|
+
participants: [
|
|
249
|
+
...new Set(
|
|
250
|
+
grouped.flatMap((g) => {
|
|
251
|
+
try {
|
|
252
|
+
const p = JSON.parse(g.participants ?? '[]') as string[];
|
|
253
|
+
return Array.isArray(p) ? p : [];
|
|
254
|
+
} catch {
|
|
255
|
+
return g.participants ? [g.participants] : [];
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
].filter(Boolean),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return await reply.send({ results: grouped, metadata });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return await reply
|
|
265
|
+
.code(502)
|
|
266
|
+
.send({ error: `Watcher unreachable: ${String(err)}` });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Cached facets manifest
|
|
271
|
+
let facetsCache: { data: unknown; fetchedAt: number } | null = null;
|
|
272
|
+
let facetsFetchPromise: Promise<unknown> | null = null;
|
|
273
|
+
const FACETS_CACHE_TTL_MS = 60_000; // 1 minute
|
|
274
|
+
|
|
275
|
+
fastify.get('/api/search/facets', async (request, reply) => {
|
|
276
|
+
if (request.accessMode !== 'insider') {
|
|
277
|
+
return reply.code(403).send({ error: 'Insider access required' });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const config = getConfig();
|
|
281
|
+
if (!config.watcherUrl) {
|
|
282
|
+
return reply.code(501).send({ error: 'Search not configured' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Return cached if fresh
|
|
286
|
+
if (
|
|
287
|
+
facetsCache &&
|
|
288
|
+
Date.now() - facetsCache.fetchedAt < FACETS_CACHE_TTL_MS
|
|
289
|
+
) {
|
|
290
|
+
return reply.send(facetsCache.data);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Guard against cache stampede: reuse in-flight fetch
|
|
295
|
+
if (!facetsFetchPromise) {
|
|
296
|
+
const watcherUrl = config.watcherUrl;
|
|
297
|
+
facetsFetchPromise = (async () => {
|
|
298
|
+
const watcherRes = await fetch(`${watcherUrl}/search/facets`, {
|
|
299
|
+
signal: AbortSignal.timeout(5000),
|
|
300
|
+
});
|
|
301
|
+
if (!watcherRes.ok) {
|
|
302
|
+
throw new Error(`HTTP ${String(watcherRes.status)}`);
|
|
303
|
+
}
|
|
304
|
+
const data: unknown = await watcherRes.json();
|
|
305
|
+
facetsCache = { data, fetchedAt: Date.now() };
|
|
306
|
+
return data;
|
|
307
|
+
})().finally(() => {
|
|
308
|
+
facetsFetchPromise = null;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const data = await facetsFetchPromise;
|
|
313
|
+
return await reply.send(data);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return await reply.code(502).send({
|
|
316
|
+
error: 'Failed to reach watcher',
|
|
317
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
};
|