@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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic utilities for key computation and verification
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute path-specific key: HMAC-SHA256(apiKey, normalizedPath)
|
|
9
|
+
*/
|
|
10
|
+
export function computePathKey(apiKey: string, urlPath: string): string {
|
|
11
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
12
|
+
const hash = crypto
|
|
13
|
+
.createHmac('sha256', apiKey)
|
|
14
|
+
.update(normalized)
|
|
15
|
+
.digest('hex');
|
|
16
|
+
return hash.substring(0, 32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute insider key: HMAC-SHA256(apiKey, "insider")
|
|
21
|
+
* Works for any path, grants full navigation
|
|
22
|
+
*/
|
|
23
|
+
export function computeInsiderKey(apiKey: string): string {
|
|
24
|
+
const hash = crypto
|
|
25
|
+
.createHmac('sha256', apiKey)
|
|
26
|
+
.update('insider')
|
|
27
|
+
.digest('hex');
|
|
28
|
+
return hash.substring(0, 32);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute outsider key with expiry: HMAC-SHA256(apiKey, path + "|" + expiry)
|
|
33
|
+
*/
|
|
34
|
+
export function computeOutsiderKeyWithExpiry(
|
|
35
|
+
apiKey: string,
|
|
36
|
+
urlPath: string,
|
|
37
|
+
expiry: string | number,
|
|
38
|
+
): string {
|
|
39
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
40
|
+
const data = `${normalized}|${String(expiry)}`;
|
|
41
|
+
const hash = crypto.createHmac('sha256', apiKey).update(data).digest('hex');
|
|
42
|
+
return hash.substring(0, 32);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deep share key parameters
|
|
47
|
+
*/
|
|
48
|
+
export interface DeepShareParams {
|
|
49
|
+
depth: number;
|
|
50
|
+
dirs: boolean;
|
|
51
|
+
stack: string; // lz-string compressToEncodedURIComponent'd path stack
|
|
52
|
+
exp?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compute deep share key: HMAC-SHA256(seed, path + "|" + d + "|" + dirs + "|" + stack + "|" + exp)
|
|
57
|
+
* Used for share links with depth \> 0 or dirs enabled.
|
|
58
|
+
*/
|
|
59
|
+
export function computeDeepShareKey(
|
|
60
|
+
seed: string,
|
|
61
|
+
urlPath: string,
|
|
62
|
+
params: DeepShareParams,
|
|
63
|
+
): string {
|
|
64
|
+
const normalized = urlPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
65
|
+
const data = `${normalized}|${String(params.depth)}|${params.dirs ? '1' : '0'}|${params.stack}|${params.exp ?? ''}`;
|
|
66
|
+
const hash = crypto.createHmac('sha256', seed).update(data).digest('hex');
|
|
67
|
+
return hash.substring(0, 32);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Timing-safe string comparison
|
|
72
|
+
*/
|
|
73
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
74
|
+
try {
|
|
75
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for file detection utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getContentType,
|
|
9
|
+
isInlineType,
|
|
10
|
+
looksLikeText,
|
|
11
|
+
} from './fileDetection.js';
|
|
12
|
+
|
|
13
|
+
describe('file detection', () => {
|
|
14
|
+
describe('looksLikeText', () => {
|
|
15
|
+
it('should return true for plain text', () => {
|
|
16
|
+
const buffer = Buffer.from('Hello, world!', 'utf8');
|
|
17
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return true for text with newlines', () => {
|
|
21
|
+
const buffer = Buffer.from('Line 1\nLine 2\nLine 3', 'utf8');
|
|
22
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return false for binary data with null bytes', () => {
|
|
26
|
+
const buffer = Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6c, 0x6f]); // He\0llo
|
|
27
|
+
expect(looksLikeText(buffer)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return false for binary data at start', () => {
|
|
31
|
+
const buffer = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
32
|
+
expect(looksLikeText(buffer)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should only check first 8KB', () => {
|
|
36
|
+
// Create 10KB buffer with null byte at position 9000
|
|
37
|
+
const buffer = Buffer.alloc(10240);
|
|
38
|
+
buffer.fill('A'.charCodeAt(0), 0, 9000);
|
|
39
|
+
buffer[9000] = 0;
|
|
40
|
+
buffer.fill('A'.charCodeAt(0), 9001);
|
|
41
|
+
|
|
42
|
+
// Should return true because null byte is beyond 8KB
|
|
43
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle empty buffer', () => {
|
|
47
|
+
const buffer = Buffer.alloc(0);
|
|
48
|
+
expect(looksLikeText(buffer)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('getContentType', () => {
|
|
53
|
+
it('should return correct type for text files', () => {
|
|
54
|
+
expect(getContentType('.txt')).toBe('text/plain; charset=utf-8');
|
|
55
|
+
expect(getContentType('.json')).toBe('application/json; charset=utf-8');
|
|
56
|
+
expect(getContentType('.html')).toBe('text/html; charset=utf-8');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return correct type for images', () => {
|
|
60
|
+
expect(getContentType('.png')).toBe('image/png');
|
|
61
|
+
expect(getContentType('.jpg')).toBe('image/jpeg');
|
|
62
|
+
expect(getContentType('.svg')).toBe('image/svg+xml');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return correct type for documents', () => {
|
|
66
|
+
expect(getContentType('.pdf')).toBe('application/pdf');
|
|
67
|
+
expect(getContentType('.docx')).toBe(
|
|
68
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should be case-insensitive', () => {
|
|
73
|
+
expect(getContentType('.PDF')).toBe('application/pdf');
|
|
74
|
+
expect(getContentType('.TxT')).toBe('text/plain; charset=utf-8');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return octet-stream for unknown extensions', () => {
|
|
78
|
+
expect(getContentType('.unknown')).toBe('application/octet-stream');
|
|
79
|
+
expect(getContentType('.xyz123')).toBe('application/octet-stream');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('isInlineType', () => {
|
|
84
|
+
it('should return true for text types', () => {
|
|
85
|
+
expect(isInlineType('text/plain; charset=utf-8')).toBe(true);
|
|
86
|
+
expect(isInlineType('text/html')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should return true for images', () => {
|
|
90
|
+
expect(isInlineType('image/png')).toBe(true);
|
|
91
|
+
expect(isInlineType('image/jpeg')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return true for video/audio', () => {
|
|
95
|
+
expect(isInlineType('video/mp4')).toBe(true);
|
|
96
|
+
expect(isInlineType('audio/mpeg')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return true for JSON and PDF', () => {
|
|
100
|
+
expect(isInlineType('application/json; charset=utf-8')).toBe(true);
|
|
101
|
+
expect(isInlineType('application/pdf')).toBe(true);
|
|
102
|
+
expect(isInlineType('application/xml')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return false for archives', () => {
|
|
106
|
+
expect(isInlineType('application/zip')).toBe(false);
|
|
107
|
+
expect(isInlineType('application/x-tar')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return false for executables', () => {
|
|
111
|
+
expect(isInlineType('application/octet-stream')).toBe(false);
|
|
112
|
+
expect(isInlineType('application/x-msdownload')).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File type detection and content-type mapping
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import mime from 'mime-types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Override map for extensions where mime-types returns an incorrect or
|
|
9
|
+
* unhelpful result for our use case.
|
|
10
|
+
*/
|
|
11
|
+
const OVERRIDES: Record<string, string> = {
|
|
12
|
+
'.mmd': 'text/plain; charset=utf-8',
|
|
13
|
+
'.puml': 'text/plain; charset=utf-8',
|
|
14
|
+
'.plantuml': 'text/plain; charset=utf-8',
|
|
15
|
+
'.pu': 'text/plain; charset=utf-8',
|
|
16
|
+
'.log': 'text/plain; charset=utf-8',
|
|
17
|
+
'.jsonl': 'text/plain; charset=utf-8',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect if a buffer looks like text (no null bytes in first 8KB)
|
|
22
|
+
*/
|
|
23
|
+
export function looksLikeText(buffer: Buffer): boolean {
|
|
24
|
+
const checkSize = Math.min(buffer.length, 8192);
|
|
25
|
+
for (let i = 0; i < checkSize; i++) {
|
|
26
|
+
if (buffer[i] === 0) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get content type for a file extension (with leading dot, e.g. '.md')
|
|
33
|
+
*/
|
|
34
|
+
export function getContentType(ext: string): string {
|
|
35
|
+
const dotExt = ext.startsWith('.') ? ext : `.${ext}`;
|
|
36
|
+
if (OVERRIDES[dotExt.toLowerCase()]) return OVERRIDES[dotExt.toLowerCase()];
|
|
37
|
+
// mime-types expects extension without the dot
|
|
38
|
+
return mime.contentType(dotExt.slice(1)) || 'application/octet-stream';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Standard content-type map for diagram export formats.
|
|
43
|
+
*/
|
|
44
|
+
export const DIAGRAM_CONTENT_TYPES: Record<string, string> = {
|
|
45
|
+
svg: 'image/svg+xml',
|
|
46
|
+
png: 'image/png',
|
|
47
|
+
pdf: 'application/pdf',
|
|
48
|
+
eps: 'application/postscript',
|
|
49
|
+
txt: 'text/plain; charset=utf-8',
|
|
50
|
+
latex: 'application/x-latex',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map file extensions to highlight.js language identifiers.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Check if a content type should be displayed inline
|
|
58
|
+
*/
|
|
59
|
+
export function isInlineType(contentType: string): boolean {
|
|
60
|
+
const inlineTypes = [
|
|
61
|
+
'image/',
|
|
62
|
+
'video/',
|
|
63
|
+
'audio/',
|
|
64
|
+
'text/',
|
|
65
|
+
'application/pdf',
|
|
66
|
+
'application/json',
|
|
67
|
+
'application/xml',
|
|
68
|
+
];
|
|
69
|
+
return inlineTypes.some((type) => contentType.startsWith(type));
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { formatRelativeTime, formatSize, nowIso } from './formatters.js';
|
|
8
|
+
|
|
9
|
+
describe('formatters', () => {
|
|
10
|
+
describe('formatSize', () => {
|
|
11
|
+
it('should format 0 bytes', () => {
|
|
12
|
+
expect(formatSize(0)).toBe('0 B');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should format bytes', () => {
|
|
16
|
+
expect(formatSize(500)).toBe('500 B');
|
|
17
|
+
expect(formatSize(1023)).toBe('1023 B');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should format kilobytes', () => {
|
|
21
|
+
expect(formatSize(1024)).toBe('1.0 KB');
|
|
22
|
+
expect(formatSize(1536)).toBe('1.5 KB');
|
|
23
|
+
expect(formatSize(10240)).toBe('10.0 KB');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should format megabytes', () => {
|
|
27
|
+
expect(formatSize(1048576)).toBe('1.0 MB');
|
|
28
|
+
expect(formatSize(1572864)).toBe('1.5 MB');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should format gigabytes', () => {
|
|
32
|
+
expect(formatSize(1073741824)).toBe('1.0 GB');
|
|
33
|
+
expect(formatSize(2147483648)).toBe('2.0 GB');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should format terabytes', () => {
|
|
37
|
+
expect(formatSize(1099511627776)).toBe('1.0 TB');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('formatRelativeTime', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.useFakeTimers();
|
|
44
|
+
vi.setSystemTime(new Date('2026-02-15T12:00:00Z'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return null for null input', () => {
|
|
48
|
+
expect(formatRelativeTime(null)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return null for future timestamps', () => {
|
|
52
|
+
const future = new Date('2026-02-15T13:00:00Z').toISOString();
|
|
53
|
+
expect(formatRelativeTime(future)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return "just now" for very recent timestamps', () => {
|
|
57
|
+
const recent = new Date('2026-02-15T11:59:30Z').toISOString();
|
|
58
|
+
expect(formatRelativeTime(recent)).toBe('just now');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return minutes ago', () => {
|
|
62
|
+
const mins5 = new Date('2026-02-15T11:55:00Z').toISOString();
|
|
63
|
+
expect(formatRelativeTime(mins5)).toBe('5m ago');
|
|
64
|
+
|
|
65
|
+
const mins45 = new Date('2026-02-15T11:15:00Z').toISOString();
|
|
66
|
+
expect(formatRelativeTime(mins45)).toBe('45m ago');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return hours ago', () => {
|
|
70
|
+
const hours2 = new Date('2026-02-15T10:00:00Z').toISOString();
|
|
71
|
+
expect(formatRelativeTime(hours2)).toBe('2h ago');
|
|
72
|
+
|
|
73
|
+
const hours12 = new Date('2026-02-15T00:00:00Z').toISOString();
|
|
74
|
+
expect(formatRelativeTime(hours12)).toBe('12h ago');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return days ago', () => {
|
|
78
|
+
const days1 = new Date('2026-02-14T12:00:00Z').toISOString();
|
|
79
|
+
expect(formatRelativeTime(days1)).toBe('1d ago');
|
|
80
|
+
|
|
81
|
+
const days7 = new Date('2026-02-08T12:00:00Z').toISOString();
|
|
82
|
+
expect(formatRelativeTime(days7)).toBe('7d ago');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should prioritize days over hours', () => {
|
|
86
|
+
const days1Hours5 = new Date('2026-02-14T07:00:00Z').toISOString();
|
|
87
|
+
expect(formatRelativeTime(days1Hours5)).toBe('1d ago');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('nowIso', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
vi.useFakeTimers();
|
|
94
|
+
vi.setSystemTime(new Date('2026-02-15T12:34:56.789Z'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return current time in ISO format', () => {
|
|
98
|
+
expect(nowIso()).toBe('2026-02-15T12:34:56.789Z');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should match ISO 8601 format', () => {
|
|
102
|
+
expect(nowIso()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utilities for dates, file sizes, etc.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format file size in human-readable format
|
|
7
|
+
*/
|
|
8
|
+
export function formatSize(bytes: number): string {
|
|
9
|
+
if (bytes === 0) return '0 B';
|
|
10
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
11
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
12
|
+
const value = bytes / Math.pow(1024, i);
|
|
13
|
+
const decimals = i > 0 ? 1 : 0;
|
|
14
|
+
return `${value.toFixed(decimals)} ${units[i]}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format ISO timestamp as relative time (e.g., "2h ago")
|
|
19
|
+
*/
|
|
20
|
+
export function formatRelativeTime(isoTimestamp: string | null): string | null {
|
|
21
|
+
if (!isoTimestamp) return null;
|
|
22
|
+
|
|
23
|
+
const then = new Date(isoTimestamp).getTime();
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const diffMs = now - then;
|
|
26
|
+
|
|
27
|
+
if (diffMs < 0) return null;
|
|
28
|
+
|
|
29
|
+
const mins = Math.floor(diffMs / 60000);
|
|
30
|
+
const hours = Math.floor(mins / 60);
|
|
31
|
+
const days = Math.floor(hours / 24);
|
|
32
|
+
|
|
33
|
+
if (days > 0) return `${String(days)}d ago`;
|
|
34
|
+
if (hours > 0) return `${String(hours)}h ago`;
|
|
35
|
+
if (mins > 0) return `${String(mins)}m ago`;
|
|
36
|
+
return 'just now';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get current ISO timestamp
|
|
41
|
+
*/
|
|
42
|
+
export function nowIso(): string {
|
|
43
|
+
return new Date().toISOString();
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the service package version by walking up from the caller's directory.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
function findPackageJson(startDir: string): string {
|
|
10
|
+
let dir = startDir;
|
|
11
|
+
while (dir !== path.dirname(dir)) {
|
|
12
|
+
const candidate = path.join(dir, 'package.json');
|
|
13
|
+
if (fs.existsSync(candidate)) {
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8')) as {
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
// Find our package specifically, not the monorepo root
|
|
18
|
+
if (pkg.name === '@karmaniverous/jeeves-server') return candidate;
|
|
19
|
+
}
|
|
20
|
+
dir = path.dirname(dir);
|
|
21
|
+
}
|
|
22
|
+
throw new Error('Could not find @karmaniverous/jeeves-server package.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const pkgPath = findPackageJson(__dirname);
|
|
27
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
|
|
28
|
+
|
|
29
|
+
/** The package version of the jeeves-server service package. */
|
|
30
|
+
export const packageVersion: string = pkg.version;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform abstraction for filesystem operations.
|
|
3
|
+
*
|
|
4
|
+
* Handles the differences between Windows (drive letters, backslashes)
|
|
5
|
+
* and Linux (mount points, forward slashes) for URL path ↔ filesystem path conversion.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
12
|
+
|
|
13
|
+
interface RootEntry {
|
|
14
|
+
/** URL-safe identifier (drive letter lowercase on Windows, mount name on Linux) */
|
|
15
|
+
id: string;
|
|
16
|
+
/** Display label */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Filesystem path */
|
|
19
|
+
fsPath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Discover available filesystem roots.
|
|
24
|
+
* Windows: enumerate accessible drive letters.
|
|
25
|
+
* Linux: return configured roots or default to '/'.
|
|
26
|
+
*/
|
|
27
|
+
export function getRoots(
|
|
28
|
+
configuredRoots?: Record<string, string>,
|
|
29
|
+
): RootEntry[] {
|
|
30
|
+
if (IS_WINDOWS) {
|
|
31
|
+
const roots: RootEntry[] = [];
|
|
32
|
+
for (let code = 65; code <= 90; code++) {
|
|
33
|
+
const letter = String.fromCharCode(code);
|
|
34
|
+
const drivePath = `${letter}:\\`;
|
|
35
|
+
try {
|
|
36
|
+
fs.accessSync(drivePath, fs.constants.R_OK);
|
|
37
|
+
roots.push({
|
|
38
|
+
id: letter.toLowerCase(),
|
|
39
|
+
label: `${letter}:`,
|
|
40
|
+
fsPath: drivePath,
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
// Drive not accessible
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return roots;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Linux: use configured roots or default to filesystem root
|
|
50
|
+
if (configuredRoots && Object.keys(configuredRoots).length > 0) {
|
|
51
|
+
return Object.entries(configuredRoots).map(([id, fsPath]) => ({
|
|
52
|
+
id,
|
|
53
|
+
label: fsPath,
|
|
54
|
+
fsPath: fsPath.endsWith('/') ? fsPath : fsPath + '/',
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [{ id: 'root', label: '/', fsPath: '/' }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert a URL path to a filesystem path.
|
|
63
|
+
*
|
|
64
|
+
* URL paths use forward slashes and start with the root id:
|
|
65
|
+
* Windows: /e/jeeves-server/README.md → E:\\jeeves-server\\README.md
|
|
66
|
+
* Linux: /home/user/docs/README.md → /home/user/docs/README.md
|
|
67
|
+
* /root/docs/README.md → /docs/README.md (if root id is "root" mapping to "/")
|
|
68
|
+
*/
|
|
69
|
+
export function urlPathToFs(
|
|
70
|
+
urlPath: string,
|
|
71
|
+
roots: RootEntry[],
|
|
72
|
+
): string | null {
|
|
73
|
+
const normalized = urlPath.replace(/^\/+/, '');
|
|
74
|
+
if (!normalized) return null;
|
|
75
|
+
|
|
76
|
+
if (IS_WINDOWS) {
|
|
77
|
+
// First segment is the drive letter
|
|
78
|
+
const slashIdx = normalized.indexOf('/');
|
|
79
|
+
const driveLetter =
|
|
80
|
+
slashIdx >= 0 ? normalized.substring(0, slashIdx) : normalized;
|
|
81
|
+
const rest = slashIdx >= 0 ? normalized.substring(slashIdx + 1) : '';
|
|
82
|
+
|
|
83
|
+
if (driveLetter.length !== 1) return null;
|
|
84
|
+
const fsPath = `${driveLetter.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
|
|
85
|
+
return fsPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Linux: match against configured roots
|
|
89
|
+
for (const root of roots) {
|
|
90
|
+
if (normalized.startsWith(root.id + '/') || normalized === root.id) {
|
|
91
|
+
const rest = normalized.substring(root.id.length);
|
|
92
|
+
// rest starts with '/' or is empty
|
|
93
|
+
const fsPath = root.fsPath.replace(/\/+$/, '') + (rest || '/');
|
|
94
|
+
return fsPath;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert a filesystem path to a URL path.
|
|
103
|
+
*
|
|
104
|
+
* Windows: E:\\jeeves-server\\README.md → /e/jeeves-server/README.md
|
|
105
|
+
* Linux: /home/user/docs/README.md → /home/user/docs/README.md (root="root" → "/")
|
|
106
|
+
*/
|
|
107
|
+
export function fsPathToUrl(fsPath: string, roots: RootEntry[]): string {
|
|
108
|
+
if (IS_WINDOWS) {
|
|
109
|
+
return (
|
|
110
|
+
'/' +
|
|
111
|
+
fsPath
|
|
112
|
+
.replace(/\\/g, '/')
|
|
113
|
+
.replace(/^([A-Za-z]):/, (_, d: string) => d.toLowerCase())
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Linux: find the matching root and prepend the root id
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
const rootFs = root.fsPath.replace(/\/+$/, '');
|
|
120
|
+
if (fsPath === rootFs || fsPath.startsWith(rootFs + '/')) {
|
|
121
|
+
const rest = fsPath.substring(rootFs.length); // starts with '/' or is empty
|
|
122
|
+
if (root.id === 'root' && root.fsPath === '/') {
|
|
123
|
+
// Default root — URL path is just the fs path
|
|
124
|
+
return rest || '/';
|
|
125
|
+
}
|
|
126
|
+
return '/' + root.id + rest;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fallback: return as-is with forward slashes
|
|
131
|
+
return fsPath.replace(/\\/g, '/');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Split a filesystem path into breadcrumb parts.
|
|
136
|
+
* Returns [\{label, urlPath\}] from root to leaf.
|
|
137
|
+
*/
|
|
138
|
+
export function breadcrumbParts(
|
|
139
|
+
fsPath: string,
|
|
140
|
+
roots: RootEntry[],
|
|
141
|
+
): { label: string; path: string }[] {
|
|
142
|
+
const urlPath = fsPathToUrl(fsPath, roots);
|
|
143
|
+
const parts = urlPath
|
|
144
|
+
.replace(/^\/+/, '')
|
|
145
|
+
.split('/')
|
|
146
|
+
.filter((p) => p);
|
|
147
|
+
|
|
148
|
+
return parts.map((_part, i) => {
|
|
149
|
+
const accumulated = parts.slice(0, i + 1).join('/');
|
|
150
|
+
return { label: parts[i], path: accumulated };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Recursively calculate total size of a directory in bytes.
|
|
156
|
+
*/
|
|
157
|
+
export function getDirSize(dirPath: string): number {
|
|
158
|
+
let totalSize = 0;
|
|
159
|
+
try {
|
|
160
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
163
|
+
try {
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
totalSize += getDirSize(entryPath);
|
|
166
|
+
} else {
|
|
167
|
+
const s = fs.statSync(entryPath);
|
|
168
|
+
totalSize += s.size;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
/* skip inaccessible */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
/* skip inaccessible */
|
|
176
|
+
}
|
|
177
|
+
return totalSize;
|
|
178
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server state management (key rotation tracking, etc.)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
import { getConfig } from '../config/index.js';
|
|
8
|
+
import type { ServerState } from '../config/types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load state from file
|
|
12
|
+
*/
|
|
13
|
+
export function loadState(): ServerState {
|
|
14
|
+
const { stateFile } = getConfig();
|
|
15
|
+
try {
|
|
16
|
+
if (fs.existsSync(stateFile)) {
|
|
17
|
+
const content = fs.readFileSync(stateFile, 'utf8');
|
|
18
|
+
return JSON.parse(content) as ServerState;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Ignore errors, return empty state
|
|
22
|
+
}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Save state to file
|
|
28
|
+
*/
|
|
29
|
+
export function saveState(state: ServerState): void {
|
|
30
|
+
const { stateFile } = getConfig();
|
|
31
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set key rotation timestamp
|
|
36
|
+
*/
|
|
37
|
+
export function setKeyRotationTimestamp(timestamp: string): void {
|
|
38
|
+
const state = loadState();
|
|
39
|
+
state.keyRotatedAt = timestamp;
|
|
40
|
+
saveState(state);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Set an insider's auto-generated key in state
|
|
45
|
+
*/
|
|
46
|
+
export function setInsiderKey(
|
|
47
|
+
email: string,
|
|
48
|
+
seed: string,
|
|
49
|
+
createdAt: string,
|
|
50
|
+
): void {
|
|
51
|
+
const state = loadState();
|
|
52
|
+
if (!state.insiderKeys) state.insiderKeys = {};
|
|
53
|
+
state.insiderKeys[email.toLowerCase()] = { seed, createdAt };
|
|
54
|
+
saveState(state);
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Diagram Retry Test
|
|
2
|
+
|
|
3
|
+
## Valid Diagram
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
graph LR
|
|
7
|
+
A[Start] --> B[Process]
|
|
8
|
+
B --> C[End]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Invalid Diagram (should fail)
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
graph LR
|
|
15
|
+
A[Start] --> B[Process]
|
|
16
|
+
B --> C[Success!]
|
|
17
|
+
C --> D[End]
|
|
18
|
+
```
|