@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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config resolution — transforms raw validated config into runtime types.
|
|
3
|
+
*
|
|
4
|
+
* Handles: key resolution, insider merging with state, PlantUML server defaults,
|
|
5
|
+
* scope normalization, internal key derivation.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { computeInsiderKey } from '../util/crypto.js';
|
|
10
|
+
/**
|
|
11
|
+
* Normalize any scopes format to \{ allow, deny \}.
|
|
12
|
+
* - undefined/null → null (unrestricted)
|
|
13
|
+
* - string → \{ allow: [string], deny: [] \}
|
|
14
|
+
* - string[] → \{ allow: string[], deny: [] \}
|
|
15
|
+
* - \{ allow?, deny? \} → \{ allow: allow ?? ['/**'], deny: deny ?? [] \}
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeScopes(raw) {
|
|
18
|
+
if (raw === undefined || raw === null)
|
|
19
|
+
return null;
|
|
20
|
+
if (typeof raw === 'string')
|
|
21
|
+
return { allow: [raw], deny: [] };
|
|
22
|
+
if (Array.isArray(raw))
|
|
23
|
+
return { allow: raw, deny: [] };
|
|
24
|
+
if (typeof raw === 'object') {
|
|
25
|
+
const obj = raw;
|
|
26
|
+
return {
|
|
27
|
+
allow: obj.allow ?? ['/**'],
|
|
28
|
+
deny: obj.deny ?? [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve raw key entries to ResolvedKey[].
|
|
35
|
+
*/
|
|
36
|
+
export function resolveKeys(keys) {
|
|
37
|
+
return Object.entries(keys).map(([name, entry]) => {
|
|
38
|
+
if (typeof entry === 'string') {
|
|
39
|
+
return { name, seed: entry, scopes: null };
|
|
40
|
+
}
|
|
41
|
+
return { name, seed: entry.key, scopes: normalizeScopes(entry.scopes) };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve insider entries by merging config (identity + scopes) with state (keys).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveInsiders(insiders, stateFile) {
|
|
48
|
+
let serverState = {};
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(stateFile)) {
|
|
51
|
+
serverState = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* empty state */
|
|
56
|
+
}
|
|
57
|
+
return Object.entries(insiders).map(([rawEmail, entry]) => {
|
|
58
|
+
const email = rawEmail.toLowerCase();
|
|
59
|
+
const scopes = normalizeScopes(entry.scopes);
|
|
60
|
+
const stateKey = serverState.insiderKeys?.[email];
|
|
61
|
+
return {
|
|
62
|
+
email,
|
|
63
|
+
seed: stateKey?.seed ?? '',
|
|
64
|
+
scopes,
|
|
65
|
+
keyCreatedAt: stateKey?.createdAt ?? null,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve PlantUML config with auto-discovery and community server fallback.
|
|
71
|
+
*
|
|
72
|
+
* If no jarPath is configured, checks for a bundled jar at vendor/plantuml.jar
|
|
73
|
+
* (downloaded by the postinstall script).
|
|
74
|
+
*/
|
|
75
|
+
export function resolvePlantuml(config, rootDir) {
|
|
76
|
+
const COMMUNITY = 'https://www.plantuml.com/plantuml';
|
|
77
|
+
const servers = config?.servers ? [...config.servers] : [];
|
|
78
|
+
if (!servers.includes(COMMUNITY))
|
|
79
|
+
servers.push(COMMUNITY);
|
|
80
|
+
let jarPath = config?.jarPath;
|
|
81
|
+
if (!jarPath && rootDir) {
|
|
82
|
+
const vendorJar = path.join(rootDir, 'vendor', 'plantuml.jar');
|
|
83
|
+
if (fs.existsSync(vendorJar)) {
|
|
84
|
+
jarPath = vendorJar;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { jarPath, javaPath: config?.javaPath, servers };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive the internal insider key from resolved keys.
|
|
91
|
+
*/
|
|
92
|
+
export function deriveInternalKey(resolvedKeys) {
|
|
93
|
+
const internalKey = resolvedKeys.find((k) => k.name === '_internal');
|
|
94
|
+
return internalKey ? computeInsiderKey(internalKey.seed) : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build the full RuntimeConfig from validated config, resolved runtime values, and paths.
|
|
98
|
+
*
|
|
99
|
+
* Centralizes the mapping from parsed config + resolved values → RuntimeConfig,
|
|
100
|
+
* keeping loadConfig focused on loading/validation and this module focused on resolution.
|
|
101
|
+
*/
|
|
102
|
+
export function buildRuntimeConfig(config, rootDir, configPath) {
|
|
103
|
+
const stateFile = path.join(rootDir, 'state.json');
|
|
104
|
+
const resolvedKeys = resolveKeys(config.keys);
|
|
105
|
+
const resolvedInsiders = resolveInsiders(config.insiders, stateFile);
|
|
106
|
+
return {
|
|
107
|
+
port: config.port,
|
|
108
|
+
eventTimeoutMs: config.eventTimeoutMs,
|
|
109
|
+
eventLogPurgeMs: config.eventLogPurgeMs,
|
|
110
|
+
maxZipSizeMb: config.maxZipSizeMb,
|
|
111
|
+
chromePath: config.chromePath,
|
|
112
|
+
roots: config.roots,
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
114
|
+
mermaidCliPath: config.mermaidCliPath,
|
|
115
|
+
plantuml: resolvePlantuml(config.plantuml, rootDir),
|
|
116
|
+
outsiderPolicy: normalizeScopes(config.outsiderPolicy) ?? null,
|
|
117
|
+
events: config.events,
|
|
118
|
+
authModes: config.auth.modes,
|
|
119
|
+
resolvedKeys,
|
|
120
|
+
resolvedInsiders,
|
|
121
|
+
googleAuth: config.auth.google ?? null,
|
|
122
|
+
sessionSecret: config.auth.sessionSecret ?? null,
|
|
123
|
+
internalInsiderKey: deriveInternalKey(resolvedKeys),
|
|
124
|
+
runnerUrl: config.runnerUrl,
|
|
125
|
+
watcherUrl: config.watcherUrl,
|
|
126
|
+
diagramCachePath: config.diagramCachePath,
|
|
127
|
+
configPath,
|
|
128
|
+
eventsLog: path.join(rootDir, 'logs', 'webhook-events.jsonl'),
|
|
129
|
+
stateFile,
|
|
130
|
+
eventQueuePath: path.join(rootDir, 'logs', 'event-queue.jsonl'),
|
|
131
|
+
eventQueueCursorPath: path.join(rootDir, 'logs', 'event-queue.cursor'),
|
|
132
|
+
eventLogPath: path.join(rootDir, 'logs', 'event-log.jsonl'),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { buildRuntimeConfig, deriveInternalKey, normalizeScopes, resolveInsiders, resolveKeys, resolvePlantuml, } from './resolve.js';
|
|
6
|
+
describe('normalizeScopes', () => {
|
|
7
|
+
it('returns null for undefined', () => {
|
|
8
|
+
expect(normalizeScopes(undefined)).toBe(null);
|
|
9
|
+
});
|
|
10
|
+
it('returns null for null', () => {
|
|
11
|
+
expect(normalizeScopes(null)).toBe(null);
|
|
12
|
+
});
|
|
13
|
+
it('wraps a string in allow array', () => {
|
|
14
|
+
expect(normalizeScopes('/docs')).toEqual({ allow: ['/docs'], deny: [] });
|
|
15
|
+
});
|
|
16
|
+
it('wraps an array as allow', () => {
|
|
17
|
+
expect(normalizeScopes(['/a', '/b'])).toEqual({
|
|
18
|
+
allow: ['/a', '/b'],
|
|
19
|
+
deny: [],
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
it('fills defaults for partial object', () => {
|
|
23
|
+
expect(normalizeScopes({ deny: ['/secret'] })).toEqual({
|
|
24
|
+
allow: ['/**'],
|
|
25
|
+
deny: ['/secret'],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('passes through complete object', () => {
|
|
29
|
+
const scopes = { allow: ['/a'], deny: ['/b'] };
|
|
30
|
+
expect(normalizeScopes(scopes)).toEqual(scopes);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('resolveKeys', () => {
|
|
34
|
+
it('handles string key entries', () => {
|
|
35
|
+
const result = resolveKeys({ primary: 'seed123' });
|
|
36
|
+
expect(result).toEqual([
|
|
37
|
+
{ name: 'primary', seed: 'seed123', scopes: null },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
it('handles object key entries with scopes', () => {
|
|
41
|
+
const result = resolveKeys({
|
|
42
|
+
scoped: { key: 'seed456', scopes: ['/docs'] },
|
|
43
|
+
});
|
|
44
|
+
expect(result[0].name).toBe('scoped');
|
|
45
|
+
expect(result[0].seed).toBe('seed456');
|
|
46
|
+
expect(result[0].scopes).toEqual({ allow: ['/docs'], deny: [] });
|
|
47
|
+
});
|
|
48
|
+
it('handles mixed entries', () => {
|
|
49
|
+
const result = resolveKeys({
|
|
50
|
+
plain: 'abc',
|
|
51
|
+
complex: { key: 'def', scopes: { allow: ['/x'], deny: ['/y'] } },
|
|
52
|
+
});
|
|
53
|
+
expect(result).toHaveLength(2);
|
|
54
|
+
expect(result[0].scopes).toBe(null);
|
|
55
|
+
expect(result[1].scopes).toEqual({ allow: ['/x'], deny: ['/y'] });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('resolveInsiders', () => {
|
|
59
|
+
let tmpDir;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jeeves-resolve-'));
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
it('normalizes email to lowercase', () => {
|
|
67
|
+
const stateFile = path.join(tmpDir, 'state.json');
|
|
68
|
+
const result = resolveInsiders({ 'Test@Example.COM': {} }, stateFile);
|
|
69
|
+
expect(result[0].email).toBe('test@example.com');
|
|
70
|
+
});
|
|
71
|
+
it('merges state keys when available', () => {
|
|
72
|
+
const stateFile = path.join(tmpDir, 'state.json');
|
|
73
|
+
fs.writeFileSync(stateFile, JSON.stringify({
|
|
74
|
+
insiderKeys: {
|
|
75
|
+
'test@example.com': { seed: 'stateseed', createdAt: '2026-01-01' },
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
const result = resolveInsiders({ 'test@example.com': {} }, stateFile);
|
|
79
|
+
expect(result[0].seed).toBe('stateseed');
|
|
80
|
+
expect(result[0].keyCreatedAt).toBe('2026-01-01');
|
|
81
|
+
});
|
|
82
|
+
it('returns empty seed when no state exists', () => {
|
|
83
|
+
const stateFile = path.join(tmpDir, 'state.json');
|
|
84
|
+
const result = resolveInsiders({ 'new@example.com': {} }, stateFile);
|
|
85
|
+
expect(result[0].seed).toBe('');
|
|
86
|
+
expect(result[0].keyCreatedAt).toBe(null);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('resolvePlantuml', () => {
|
|
90
|
+
it('appends community server as fallback', () => {
|
|
91
|
+
const result = resolvePlantuml();
|
|
92
|
+
expect(result.servers).toContain('https://www.plantuml.com/plantuml');
|
|
93
|
+
});
|
|
94
|
+
it('does not duplicate community server if already listed', () => {
|
|
95
|
+
const result = resolvePlantuml({
|
|
96
|
+
servers: ['https://www.plantuml.com/plantuml'],
|
|
97
|
+
});
|
|
98
|
+
expect(result.servers.filter((s) => s === 'https://www.plantuml.com/plantuml')).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
it('preserves configured servers before community', () => {
|
|
101
|
+
const result = resolvePlantuml({
|
|
102
|
+
servers: ['https://private.example.com'],
|
|
103
|
+
});
|
|
104
|
+
expect(result.servers[0]).toBe('https://private.example.com');
|
|
105
|
+
expect(result.servers[1]).toBe('https://www.plantuml.com/plantuml');
|
|
106
|
+
});
|
|
107
|
+
it('passes through jarPath and javaPath', () => {
|
|
108
|
+
const result = resolvePlantuml({
|
|
109
|
+
jarPath: '/opt/plantuml.jar',
|
|
110
|
+
javaPath: '/usr/bin/java',
|
|
111
|
+
});
|
|
112
|
+
expect(result.jarPath).toBe('/opt/plantuml.jar');
|
|
113
|
+
expect(result.javaPath).toBe('/usr/bin/java');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('deriveInternalKey', () => {
|
|
117
|
+
it('returns null when no _internal key exists', () => {
|
|
118
|
+
const keys = [{ name: 'primary', seed: 'abc', scopes: null }];
|
|
119
|
+
expect(deriveInternalKey(keys)).toBe(null);
|
|
120
|
+
});
|
|
121
|
+
it('derives a key from _internal seed', () => {
|
|
122
|
+
const keys = [{ name: '_internal', seed: 'x'.repeat(64), scopes: null }];
|
|
123
|
+
const result = deriveInternalKey(keys);
|
|
124
|
+
expect(result).toBeTypeOf('string');
|
|
125
|
+
expect(result.length).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('buildRuntimeConfig', () => {
|
|
129
|
+
it('constructs correct path fields', () => {
|
|
130
|
+
const config = {
|
|
131
|
+
port: 1934,
|
|
132
|
+
eventTimeoutMs: 30000,
|
|
133
|
+
eventLogPurgeMs: 2592000000,
|
|
134
|
+
maxZipSizeMb: 100,
|
|
135
|
+
chromePath: '/usr/bin/chrome',
|
|
136
|
+
events: {},
|
|
137
|
+
auth: { modes: ['keys'] },
|
|
138
|
+
keys: { primary: 'a'.repeat(64) },
|
|
139
|
+
insiders: {},
|
|
140
|
+
};
|
|
141
|
+
const result = buildRuntimeConfig(config, '/srv/jeeves', '/srv/jeeves/config.json');
|
|
142
|
+
expect(result.stateFile).toBe(path.join('/srv/jeeves', 'state.json'));
|
|
143
|
+
expect(result.eventsLog).toBe(path.join('/srv/jeeves', 'logs', 'webhook-events.jsonl'));
|
|
144
|
+
expect(result.configPath).toBe('/srv/jeeves/config.json');
|
|
145
|
+
expect(result.port).toBe(1934);
|
|
146
|
+
expect(result.authModes).toEqual(['keys']);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Supported authentication methods */
|
|
3
|
+
export const authModeSchema = z.enum(['google', 'keys']);
|
|
4
|
+
/** Google OAuth configuration */
|
|
5
|
+
export const googleAuthSchema = z.object({
|
|
6
|
+
clientId: z.string().min(1),
|
|
7
|
+
clientSecret: z.string().min(1),
|
|
8
|
+
});
|
|
9
|
+
/** Auth configuration */
|
|
10
|
+
export const authSchema = z.object({
|
|
11
|
+
/** Active authentication methods. Order determines priority. */
|
|
12
|
+
modes: z
|
|
13
|
+
.array(authModeSchema)
|
|
14
|
+
.min(1, { message: 'At least one auth mode is required' }),
|
|
15
|
+
/** Google OAuth config. Required if modes includes "google". */
|
|
16
|
+
google: googleAuthSchema.optional(),
|
|
17
|
+
/** Session cookie signing secret. Required if modes includes "google". */
|
|
18
|
+
sessionSecret: z.string().min(1).optional(),
|
|
19
|
+
});
|
|
20
|
+
/** Event webhook configuration */
|
|
21
|
+
export const eventConfigSchema = z.object({
|
|
22
|
+
schema: z.record(z.string(), z.unknown()),
|
|
23
|
+
cmd: z.string(),
|
|
24
|
+
map: z.record(z.string(), z.unknown()).optional(),
|
|
25
|
+
timeoutMs: z.number().positive().optional(),
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Scopes configuration â€" controls which paths a user/key can access.
|
|
29
|
+
*
|
|
30
|
+
* Three forms:
|
|
31
|
+
* - `string` â€" single allow pattern (e.g. '/d/*')
|
|
32
|
+
* - `string[]` — array of allow patterns (shorthand for \{ allow: [...] \})
|
|
33
|
+
* - `\{ allow?: string[], deny?: string[] \}` — explicit allow/deny rules
|
|
34
|
+
*
|
|
35
|
+
* Semantics:
|
|
36
|
+
* - Path must match at least one allow rule AND NOT match any deny rule
|
|
37
|
+
* - Omitting `allow` = implicit ['/*'] (allow everything)
|
|
38
|
+
* - Omitting `deny` = no exclusions
|
|
39
|
+
* - Omitting scopes entirely = unrestricted access
|
|
40
|
+
*/
|
|
41
|
+
export const scopesObjectSchema = z.object({
|
|
42
|
+
allow: z.array(z.string()).optional(),
|
|
43
|
+
deny: z.array(z.string()).optional(),
|
|
44
|
+
});
|
|
45
|
+
export const scopesSchema = z.union([
|
|
46
|
+
z.string(),
|
|
47
|
+
z.array(z.string()),
|
|
48
|
+
scopesObjectSchema,
|
|
49
|
+
]);
|
|
50
|
+
/** Insider entry (identity + scopes only; keys are in state.json) */
|
|
51
|
+
export const insiderEntrySchema = z.object({
|
|
52
|
+
scopes: scopesSchema.optional(),
|
|
53
|
+
});
|
|
54
|
+
/** Key entry â€" plain string (seed, no scopes) or object with key + optional scopes */
|
|
55
|
+
export const keyEntrySchema = z.union([
|
|
56
|
+
z.string().min(1),
|
|
57
|
+
z.object({
|
|
58
|
+
key: z.string().min(1),
|
|
59
|
+
scopes: scopesSchema.optional(),
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
/** Top-level Jeeves Server configuration */
|
|
63
|
+
export const jeevesConfigSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
port: z.number().int().positive().default(1934),
|
|
66
|
+
chromePath: z.string().min(1),
|
|
67
|
+
auth: authSchema,
|
|
68
|
+
insiders: z.record(z.email(), insiderEntrySchema).default({}),
|
|
69
|
+
keys: z.record(z.string(), keyEntrySchema).default({}),
|
|
70
|
+
events: z.record(z.string(), eventConfigSchema).default({}),
|
|
71
|
+
eventTimeoutMs: z.number().positive().default(30_000),
|
|
72
|
+
eventLogPurgeMs: z.number().positive().default(2_592_000_000),
|
|
73
|
+
/** Maximum directory size in MB for ZIP export. Directories exceeding this are refused. */
|
|
74
|
+
maxZipSizeMb: z.number().positive().default(100),
|
|
75
|
+
/**
|
|
76
|
+
* Filesystem roots for the file browser (Linux only).
|
|
77
|
+
* Map of id â†' filesystem path. On Windows this is ignored (drives are auto-discovered).
|
|
78
|
+
* Example: \{ home: '/home/user', projects: '/opt/projects' \}
|
|
79
|
+
* Default: \{ root: '/' \}
|
|
80
|
+
*/
|
|
81
|
+
roots: z.record(z.string(), z.string()).optional(),
|
|
82
|
+
/**
|
|
83
|
+
* URL of the jeeves-runner API for process dashboard proxy.
|
|
84
|
+
* Default: 'http://127.0.0.1:3100'
|
|
85
|
+
*/
|
|
86
|
+
runnerUrl: z.url().optional(),
|
|
87
|
+
/** @deprecated Mermaid is now bundled. This field is ignored but kept for backward compatibility. */
|
|
88
|
+
mermaidCliPath: z.string().optional(),
|
|
89
|
+
/**
|
|
90
|
+
* PlantUML rendering configuration.
|
|
91
|
+
* - jarPath: local PlantUML jar (requires Java). Tried first â€" supports !include.
|
|
92
|
+
* - servers: fallback PlantUML server URLs, tried in order.
|
|
93
|
+
* The public community server (https://www.plantuml.com/plantuml) is always
|
|
94
|
+
* appended as the last resort unless explicitly listed.
|
|
95
|
+
* If omitted entirely, only the community server is used.
|
|
96
|
+
*/
|
|
97
|
+
plantuml: z
|
|
98
|
+
.object({
|
|
99
|
+
jarPath: z.string().optional(),
|
|
100
|
+
javaPath: z.string().optional(),
|
|
101
|
+
servers: z.array(z.url()).optional(),
|
|
102
|
+
})
|
|
103
|
+
.optional(),
|
|
104
|
+
/**
|
|
105
|
+
* Directory for cached rendered diagrams (content-addressed by source hash).
|
|
106
|
+
* Defaults to `.diagram-cache` in the server working directory.
|
|
107
|
+
*/
|
|
108
|
+
diagramCachePath: z.string().optional(),
|
|
109
|
+
/**
|
|
110
|
+
* URL of the jeeves-watcher API for semantic search.
|
|
111
|
+
* When set, the search UI appears in the header. Example: 'http://localhost:3458'
|
|
112
|
+
*/
|
|
113
|
+
watcherUrl: z.url().optional(),
|
|
114
|
+
/**
|
|
115
|
+
* Global outsider policy â€" constrains which paths are eligible for outsider sharing.
|
|
116
|
+
* Uses the same allow/deny model as insider scopes.
|
|
117
|
+
* If omitted, all paths are shareable with outsiders.
|
|
118
|
+
*/
|
|
119
|
+
outsiderPolicy: scopesObjectSchema.optional(),
|
|
120
|
+
})
|
|
121
|
+
.superRefine((config, ctx) => {
|
|
122
|
+
// Google auth mode requires google config + sessionSecret
|
|
123
|
+
if (config.auth.modes.includes('google')) {
|
|
124
|
+
if (!config.auth.google) {
|
|
125
|
+
ctx.addIssue({
|
|
126
|
+
code: 'custom',
|
|
127
|
+
message: 'auth.google is required when auth.modes includes "google"',
|
|
128
|
+
path: ['auth', 'google'],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!config.auth.sessionSecret) {
|
|
132
|
+
ctx.addIssue({
|
|
133
|
+
code: 'custom',
|
|
134
|
+
message: 'auth.sessionSecret is required when auth.modes includes "google"',
|
|
135
|
+
path: ['auth', 'sessionSecret'],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Keys auth mode requires at least one key
|
|
140
|
+
if (config.auth.modes.includes('keys') &&
|
|
141
|
+
Object.keys(config.keys).length === 0) {
|
|
142
|
+
ctx.addIssue({
|
|
143
|
+
code: 'custom',
|
|
144
|
+
message: 'At least one key is required when auth.modes includes "keys"',
|
|
145
|
+
path: ['keys'],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// _internal and _plugin keys must not have scopes
|
|
149
|
+
for (const reserved of ['_internal', '_plugin']) {
|
|
150
|
+
const key = config.keys[reserved];
|
|
151
|
+
if (key && typeof key === 'object' && 'scopes' in key && key.scopes) {
|
|
152
|
+
ctx.addIssue({
|
|
153
|
+
code: 'custom',
|
|
154
|
+
message: `${reserved} key must not have scopes (it is always unscoped)`,
|
|
155
|
+
path: ['keys', reserved],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* Deep-walks config objects and replaces `${VAR_NAME}` patterns with environment variable values.
|
|
5
|
+
* Ported from jeeves-watcher — candidate for hoisting to shared `@karmaniverous/jeeves-config`.
|
|
6
|
+
*/
|
|
7
|
+
const ENV_PATTERN = /\$\{([^}]+)\}/g;
|
|
8
|
+
/**
|
|
9
|
+
* Replace `${VAR_NAME}` patterns in a string with `process.env.VAR_NAME`.
|
|
10
|
+
*
|
|
11
|
+
* @param value - The string to process.
|
|
12
|
+
* @returns The string with resolved env vars; unresolvable expressions left untouched.
|
|
13
|
+
*/
|
|
14
|
+
function substituteString(value) {
|
|
15
|
+
return value.replace(ENV_PATTERN, (match, varName) => {
|
|
16
|
+
const envValue = process.env[varName];
|
|
17
|
+
if (envValue === undefined)
|
|
18
|
+
return match;
|
|
19
|
+
return envValue;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Deep-walk a value and substitute `${VAR_NAME}` patterns in all string values.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The value to walk (object, array, or primitive).
|
|
26
|
+
* @returns A new value with all env var references resolved.
|
|
27
|
+
*/
|
|
28
|
+
export function substituteEnvVars(value) {
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
return substituteString(value);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map((item) => substituteEnvVars(item));
|
|
34
|
+
}
|
|
35
|
+
if (value !== null &&
|
|
36
|
+
typeof value === 'object' &&
|
|
37
|
+
Object.getPrototypeOf(value) === Object.prototype) {
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const [key, val] of Object.entries(value)) {
|
|
40
|
+
result[key] = substituteEnvVars(val);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { substituteEnvVars } from './substituteEnvVars.js';
|
|
3
|
+
describe('substituteEnvVars', () => {
|
|
4
|
+
const ORIGINAL_ENV = process.env;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
process.env = { ...ORIGINAL_ENV };
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
process.env = ORIGINAL_ENV;
|
|
10
|
+
});
|
|
11
|
+
it('replaces a single env var in a string', () => {
|
|
12
|
+
process.env['TEST_VAR'] = 'hello';
|
|
13
|
+
expect(substituteEnvVars('${TEST_VAR}')).toBe('hello');
|
|
14
|
+
});
|
|
15
|
+
it('replaces multiple env vars in a string', () => {
|
|
16
|
+
process.env['HOST'] = 'localhost';
|
|
17
|
+
process.env['PORT'] = '3456';
|
|
18
|
+
expect(substituteEnvVars('http://${HOST}:${PORT}')).toBe('http://localhost:3456');
|
|
19
|
+
});
|
|
20
|
+
it('leaves unresolvable expressions untouched', () => {
|
|
21
|
+
expect(substituteEnvVars('${MISSING_VAR}')).toBe('${MISSING_VAR}');
|
|
22
|
+
});
|
|
23
|
+
it('handles non-string primitives unchanged', () => {
|
|
24
|
+
expect(substituteEnvVars(42)).toBe(42);
|
|
25
|
+
expect(substituteEnvVars(true)).toBe(true);
|
|
26
|
+
expect(substituteEnvVars(null)).toBe(null);
|
|
27
|
+
});
|
|
28
|
+
it('deep-walks objects', () => {
|
|
29
|
+
process.env['SECRET'] = 'abc123';
|
|
30
|
+
const input = { nested: { key: '${SECRET}' }, plain: 'no-sub' };
|
|
31
|
+
expect(substituteEnvVars(input)).toEqual({
|
|
32
|
+
nested: { key: 'abc123' },
|
|
33
|
+
plain: 'no-sub',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it('deep-walks arrays', () => {
|
|
37
|
+
process.env['ITEM'] = 'resolved';
|
|
38
|
+
expect(substituteEnvVars(['${ITEM}', 'literal'])).toEqual([
|
|
39
|
+
'resolved',
|
|
40
|
+
'literal',
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
it('handles mixed nested structures', () => {
|
|
44
|
+
process.env['A'] = 'alpha';
|
|
45
|
+
const input = { list: [{ val: '${A}' }, '${A}'], num: 5 };
|
|
46
|
+
expect(substituteEnvVars(input)).toEqual({
|
|
47
|
+
list: [{ val: 'alpha' }, 'alpha'],
|
|
48
|
+
num: 5,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth status API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: /api/auth/status
|
|
5
|
+
*/
|
|
6
|
+
import { findInsider, resolveKeyAuth, resolveSessionAuth, } from '../../auth/resolve.js';
|
|
7
|
+
import { getConfig } from '../../config/index.js';
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
9
|
+
export const authStatusRoutes = async (fastify) => {
|
|
10
|
+
fastify.get('/api/auth/status', async (request, reply) => {
|
|
11
|
+
const config = getConfig();
|
|
12
|
+
// Try session cookie first
|
|
13
|
+
const sessionResult = resolveSessionAuth(config, request);
|
|
14
|
+
if (sessionResult.valid) {
|
|
15
|
+
const insider = findInsider(config.resolvedInsiders, sessionResult.email);
|
|
16
|
+
// Get picture from session cookie directly
|
|
17
|
+
const cookieValue = request.cookies?.['jeeves_session'];
|
|
18
|
+
let picture;
|
|
19
|
+
if (cookieValue) {
|
|
20
|
+
try {
|
|
21
|
+
const b64 = cookieValue.slice(0, cookieValue.lastIndexOf('.'));
|
|
22
|
+
const payload = JSON.parse(Buffer.from(b64, 'base64url').toString());
|
|
23
|
+
picture = payload.picture;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* ignore */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return reply.send({
|
|
30
|
+
authenticated: true,
|
|
31
|
+
email: sessionResult.email,
|
|
32
|
+
picture,
|
|
33
|
+
isInsider: !!insider?.seed,
|
|
34
|
+
keyCreatedAt: insider?.keyCreatedAt ?? null,
|
|
35
|
+
searchEnabled: !!config.watcherUrl,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// Try key-based auth
|
|
39
|
+
if (config.authModes.includes('keys')) {
|
|
40
|
+
const query = request.query;
|
|
41
|
+
const deepParams = query.d !== undefined && query.s !== undefined
|
|
42
|
+
? { d: query.d, dirs: query.dirs ?? '0', s: query.s }
|
|
43
|
+
: undefined;
|
|
44
|
+
const keyResult = resolveKeyAuth(config, query.path ?? '/', query.key, query.exp, deepParams);
|
|
45
|
+
if (keyResult.valid) {
|
|
46
|
+
return reply.send({
|
|
47
|
+
authenticated: true,
|
|
48
|
+
email: `key:${String(keyResult.keyName)}`,
|
|
49
|
+
isInsider: keyResult.mode === 'insider',
|
|
50
|
+
keyCreatedAt: null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return reply.send({ authenticated: false, isInsider: false });
|
|
55
|
+
});
|
|
56
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram rendering API route.
|
|
3
|
+
*
|
|
4
|
+
* Handles: /api/diagram/:type/:hash
|
|
5
|
+
*/
|
|
6
|
+
import { getDiagramSource, renderDiagramToSvg, } from '../../services/embeddedDiagrams.js';
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
8
|
+
export const diagramsRoutes = async (fastify) => {
|
|
9
|
+
fastify.get('/api/diagram/:type/:hash', async (request, reply) => {
|
|
10
|
+
const { type, hash: hashWithExt } = request.params;
|
|
11
|
+
const hash = hashWithExt.replace(/\.svg$/, '');
|
|
12
|
+
if (!['mermaid', 'plantuml'].includes(type)) {
|
|
13
|
+
return reply.code(400).send({ error: 'Invalid diagram type' });
|
|
14
|
+
}
|
|
15
|
+
if (!/^[a-f0-9]{64}$/.test(hash)) {
|
|
16
|
+
return reply.code(400).send({ error: 'Invalid hash' });
|
|
17
|
+
}
|
|
18
|
+
const entry = getDiagramSource(hash);
|
|
19
|
+
if (!entry) {
|
|
20
|
+
return reply
|
|
21
|
+
.code(404)
|
|
22
|
+
.send({ error: 'Diagram source not found (may have expired)' });
|
|
23
|
+
}
|
|
24
|
+
const svg = await renderDiagramToSvg(type, entry.source, entry.contextDir);
|
|
25
|
+
if (!svg) {
|
|
26
|
+
return reply
|
|
27
|
+
.code(500)
|
|
28
|
+
.send({ error: 'Renderer returned empty result' });
|
|
29
|
+
}
|
|
30
|
+
return reply
|
|
31
|
+
.header('Content-Type', 'image/svg+xml')
|
|
32
|
+
.header('Cache-Control', 'public, max-age=86400, immutable')
|
|
33
|
+
.send(svg);
|
|
34
|
+
});
|
|
35
|
+
};
|