@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sharing API routes.
|
|
3
|
+
*
|
|
4
|
+
* Handles: /api/share, /api/util/share-for, /api/readme-link, /api/rotate-key
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
13
|
+
|
|
14
|
+
import { _pathMatchesScopes } from '../../auth/keys.js';
|
|
15
|
+
import { findInsider } from '../../auth/resolve.js';
|
|
16
|
+
import { getConfig, resetConfig } from '../../config/index.js';
|
|
17
|
+
import { encodeStack } from '../../services/deepShareLinks.js';
|
|
18
|
+
import {
|
|
19
|
+
computeDeepShareKey,
|
|
20
|
+
computeOutsiderKeyWithExpiry,
|
|
21
|
+
computePathKey,
|
|
22
|
+
type DeepShareParams,
|
|
23
|
+
} from '../../util/crypto.js';
|
|
24
|
+
import { fsPathToUrl, getRoots } from '../../util/platform.js';
|
|
25
|
+
import { setInsiderKey } from '../../util/state.js';
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
28
|
+
export const sharingRoutes: FastifyPluginAsync = async (fastify) => {
|
|
29
|
+
const roots = getRoots(getConfig().roots);
|
|
30
|
+
|
|
31
|
+
// GET /api/readme-link
|
|
32
|
+
fastify.get('/api/readme-link', async (_request, reply) => {
|
|
33
|
+
const config = getConfig();
|
|
34
|
+
const internalKey = config.resolvedKeys.find((k) => k.name === '_internal');
|
|
35
|
+
if (!internalKey?.seed)
|
|
36
|
+
return reply.code(503).send({ error: 'No _internal key configured' });
|
|
37
|
+
|
|
38
|
+
const seed = internalKey.seed;
|
|
39
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const serverRoot = path.resolve(__dirname, '..', '..', '..');
|
|
41
|
+
const readmePath = path.join(serverRoot, 'README.md');
|
|
42
|
+
if (!fs.existsSync(readmePath))
|
|
43
|
+
return reply.code(404).send({ error: 'README.md not found' });
|
|
44
|
+
|
|
45
|
+
const urlPath = fsPathToUrl(readmePath, roots);
|
|
46
|
+
const stack = encodeStack([urlPath]);
|
|
47
|
+
const deepParams = { depth: 2, dirs: false, stack, exp: undefined };
|
|
48
|
+
const key = computeDeepShareKey(seed, urlPath, deepParams);
|
|
49
|
+
const shareUrl = `/browse${urlPath}?key=${key}&d=2&dirs=0&s=${stack}`;
|
|
50
|
+
|
|
51
|
+
return reply.send({ url: shareUrl });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// GET /api/content-link/:file — share link for content/*.md (terms, privacy)
|
|
55
|
+
fastify.get('/api/content-link/:file', async (request, reply) => {
|
|
56
|
+
const config = getConfig();
|
|
57
|
+
const internalKey = config.resolvedKeys.find((k) => k.name === '_internal');
|
|
58
|
+
if (!internalKey?.seed)
|
|
59
|
+
return reply.code(503).send({ error: 'No _internal key configured' });
|
|
60
|
+
|
|
61
|
+
const { file } = request.params as { file: string };
|
|
62
|
+
if (!/^[\w-]+$/.test(file))
|
|
63
|
+
return reply.code(400).send({ error: 'Invalid file name' });
|
|
64
|
+
|
|
65
|
+
const seed = internalKey.seed;
|
|
66
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
const serverRoot = path.resolve(__dirname, '..', '..', '..');
|
|
68
|
+
const contentPath = path.join(serverRoot, 'content', `${file}.md`);
|
|
69
|
+
if (!fs.existsSync(contentPath))
|
|
70
|
+
return reply.code(404).send({ error: `${file}.md not found` });
|
|
71
|
+
|
|
72
|
+
const urlPath = fsPathToUrl(contentPath, roots);
|
|
73
|
+
const stack = encodeStack([urlPath]);
|
|
74
|
+
const deepParams = { depth: 0, dirs: false, stack, exp: undefined };
|
|
75
|
+
const key = computeDeepShareKey(seed, urlPath, deepParams);
|
|
76
|
+
const shareUrl = `/browse${urlPath}?key=${key}&d=0&dirs=0&s=${stack}`;
|
|
77
|
+
|
|
78
|
+
return reply.send({ url: shareUrl });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// POST /api/share
|
|
82
|
+
fastify.post<{
|
|
83
|
+
Body: { path: string; expiry?: string; depth?: number; dirs?: boolean };
|
|
84
|
+
}>('/api/share', async (request, reply) => {
|
|
85
|
+
const seed = request.authSeed;
|
|
86
|
+
if (!seed) return reply.code(401).send({ error: 'Insider auth required' });
|
|
87
|
+
|
|
88
|
+
const { path: targetPath, expiry, depth, dirs } = request.body;
|
|
89
|
+
if (!targetPath) return reply.code(400).send({ error: 'path is required' });
|
|
90
|
+
|
|
91
|
+
let outsiderKey: string;
|
|
92
|
+
let shareUrl: string;
|
|
93
|
+
|
|
94
|
+
if ((depth && depth > 0) || dirs) {
|
|
95
|
+
const stack = encodeStack([targetPath]);
|
|
96
|
+
const deepParams: DeepShareParams = {
|
|
97
|
+
depth: depth ?? 0,
|
|
98
|
+
dirs: dirs ?? false,
|
|
99
|
+
stack,
|
|
100
|
+
exp: expiry,
|
|
101
|
+
};
|
|
102
|
+
outsiderKey = computeDeepShareKey(seed, targetPath, deepParams);
|
|
103
|
+
shareUrl = `/browse${targetPath}?key=${outsiderKey}&d=${String(depth ?? 0)}&dirs=${dirs ? '1' : '0'}&s=${stack}`;
|
|
104
|
+
if (expiry) shareUrl += `&exp=${expiry}`;
|
|
105
|
+
} else if (expiry) {
|
|
106
|
+
outsiderKey = computeOutsiderKeyWithExpiry(seed, targetPath, expiry);
|
|
107
|
+
shareUrl = `/browse${targetPath}?key=${outsiderKey}&exp=${expiry}`;
|
|
108
|
+
} else {
|
|
109
|
+
outsiderKey = computePathKey(seed, targetPath);
|
|
110
|
+
shareUrl = `/browse${targetPath}?key=${outsiderKey}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return reply.send({
|
|
114
|
+
url: shareUrl,
|
|
115
|
+
path: targetPath,
|
|
116
|
+
exp: expiry ?? null,
|
|
117
|
+
depth: depth ?? 0,
|
|
118
|
+
dirs: dirs ?? false,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// POST /api/rotate-key
|
|
123
|
+
fastify.post('/api/rotate-key', async (request, reply) => {
|
|
124
|
+
const insiderEmail = request.insiderEmail;
|
|
125
|
+
if (!insiderEmail)
|
|
126
|
+
return reply.code(403).send({ error: 'Insider auth required' });
|
|
127
|
+
|
|
128
|
+
const config = getConfig();
|
|
129
|
+
const insider = findInsider(config.resolvedInsiders, insiderEmail);
|
|
130
|
+
if (!insider) return reply.code(403).send({ error: 'Not an insider' });
|
|
131
|
+
|
|
132
|
+
const newSeed = crypto.randomBytes(32).toString('hex');
|
|
133
|
+
const now = new Date().toISOString();
|
|
134
|
+
setInsiderKey(insider.email, newSeed, now);
|
|
135
|
+
resetConfig();
|
|
136
|
+
|
|
137
|
+
return reply.send({ ok: true, keyCreatedAt: now });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// POST /api/util/share-for
|
|
141
|
+
fastify.post<{
|
|
142
|
+
Body: {
|
|
143
|
+
path: string;
|
|
144
|
+
insiders: string[];
|
|
145
|
+
depth?: number;
|
|
146
|
+
dirs?: boolean;
|
|
147
|
+
enforceOutsiderPolicy?: boolean;
|
|
148
|
+
};
|
|
149
|
+
}>('/api/util/share-for', async (request, reply) => {
|
|
150
|
+
const config = getConfig();
|
|
151
|
+
const sharerSeed = request.authSeed;
|
|
152
|
+
const sharerScopes = request.insiderScopes ?? null;
|
|
153
|
+
if (!sharerSeed)
|
|
154
|
+
return reply.code(401).send({ error: 'Authentication required' });
|
|
155
|
+
|
|
156
|
+
const {
|
|
157
|
+
path: targetPath,
|
|
158
|
+
insiders: audienceIds,
|
|
159
|
+
depth,
|
|
160
|
+
dirs,
|
|
161
|
+
enforceOutsiderPolicy,
|
|
162
|
+
} = request.body;
|
|
163
|
+
if (!targetPath) return reply.code(400).send({ error: 'path is required' });
|
|
164
|
+
if (!Array.isArray(audienceIds))
|
|
165
|
+
return reply.code(400).send({ error: 'insiders array is required' });
|
|
166
|
+
|
|
167
|
+
if (sharerScopes && !_pathMatchesScopes(targetPath, sharerScopes)) {
|
|
168
|
+
return reply.send({
|
|
169
|
+
url: null,
|
|
170
|
+
type: 'blocked',
|
|
171
|
+
reason: 'Sharer does not have access to this path',
|
|
172
|
+
blocked: [],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const blockedInsiders: string[] = [];
|
|
177
|
+
const unknownIds: string[] = [];
|
|
178
|
+
|
|
179
|
+
for (const id of audienceIds) {
|
|
180
|
+
const insider = findInsider(config.resolvedInsiders, id);
|
|
181
|
+
if (!insider || !insider.seed) {
|
|
182
|
+
unknownIds.push(id);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (insider.scopes && !_pathMatchesScopes(targetPath, insider.scopes)) {
|
|
186
|
+
blockedInsiders.push(id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (blockedInsiders.length > 0) {
|
|
191
|
+
return reply.send({
|
|
192
|
+
url: null,
|
|
193
|
+
type: 'blocked',
|
|
194
|
+
reason: 'Insider(s) do not have access to this path',
|
|
195
|
+
blocked: blockedInsiders,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const hasOutsiders = unknownIds.length > 0;
|
|
200
|
+
|
|
201
|
+
if (!hasOutsiders) {
|
|
202
|
+
const proto = String(request.headers['x-forwarded-proto'] || 'https');
|
|
203
|
+
const host = String(
|
|
204
|
+
request.headers['x-forwarded-host'] || request.headers.host,
|
|
205
|
+
);
|
|
206
|
+
return reply.send({
|
|
207
|
+
url: `${proto}://${host}/browse${targetPath}`,
|
|
208
|
+
type: 'insider',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const outsiderPolicy = config.outsiderPolicy;
|
|
213
|
+
const policyEnforced = enforceOutsiderPolicy !== false;
|
|
214
|
+
|
|
215
|
+
if (outsiderPolicy && policyEnforced) {
|
|
216
|
+
if (!_pathMatchesScopes(targetPath, outsiderPolicy)) {
|
|
217
|
+
return reply.send({
|
|
218
|
+
url: null,
|
|
219
|
+
type: 'policy-denied',
|
|
220
|
+
reason: 'Outsider policy does not allow sharing this path',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const shareDepth = depth ?? 0;
|
|
226
|
+
const shareDirs = dirs ?? false;
|
|
227
|
+
const stack = encodeStack([targetPath]);
|
|
228
|
+
const deepParams: DeepShareParams = {
|
|
229
|
+
depth: shareDepth,
|
|
230
|
+
dirs: shareDirs,
|
|
231
|
+
stack,
|
|
232
|
+
};
|
|
233
|
+
const outsiderKey = computeDeepShareKey(sharerSeed, targetPath, deepParams);
|
|
234
|
+
const proto = String(request.headers['x-forwarded-proto'] || 'https');
|
|
235
|
+
const host = String(
|
|
236
|
+
request.headers['x-forwarded-host'] || request.headers.host,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
let shareUrl = `${proto}://${host}/browse${targetPath}?key=${outsiderKey}`;
|
|
240
|
+
if (shareDepth > 0) shareUrl += `&d=${String(shareDepth)}`;
|
|
241
|
+
shareUrl += `&dirs=${shareDirs ? '1' : '0'}`;
|
|
242
|
+
if (stack) shareUrl += `&s=${stack}`;
|
|
243
|
+
|
|
244
|
+
const response: Record<string, unknown> = {
|
|
245
|
+
url: shareUrl,
|
|
246
|
+
type: 'outsider-share',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
outsiderPolicy &&
|
|
251
|
+
!policyEnforced &&
|
|
252
|
+
!_pathMatchesScopes(targetPath, outsiderPolicy)
|
|
253
|
+
) {
|
|
254
|
+
response.warning = 'Outsider policy would deny this path';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return reply.send(response);
|
|
258
|
+
});
|
|
259
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock config
|
|
4
|
+
const mockConfig = {
|
|
5
|
+
port: 1934,
|
|
6
|
+
chromePath: '/usr/bin/chromium',
|
|
7
|
+
authModes: ['keys'],
|
|
8
|
+
resolvedInsiders: [{ email: 'a@b.com' }, { email: 'c@d.com' }],
|
|
9
|
+
resolvedKeys: [{ name: 'primary' }],
|
|
10
|
+
events: {
|
|
11
|
+
deploy: { cmd: 'deploy.sh', schema: {} },
|
|
12
|
+
notify: { cmd: 'notify.sh', schema: {} },
|
|
13
|
+
},
|
|
14
|
+
mermaidCliPath: '/tools/mermaid',
|
|
15
|
+
plantuml: {
|
|
16
|
+
jarPath: '/tools/plantuml.jar',
|
|
17
|
+
servers: ['https://plantuml.com/plantuml'],
|
|
18
|
+
},
|
|
19
|
+
watcherUrl: null,
|
|
20
|
+
runnerUrl: null,
|
|
21
|
+
exportFormats: ['pdf', 'docx', 'zip'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
vi.mock('../../config/index.js', () => ({
|
|
25
|
+
getConfig: () => mockConfig,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Must import AFTER mock
|
|
29
|
+
const { statusRoutes } = await import('./status.js');
|
|
30
|
+
|
|
31
|
+
describe('GET /api/status', () => {
|
|
32
|
+
it('returns structured status for insider requests', async () => {
|
|
33
|
+
// Create a minimal Fastify-like test harness
|
|
34
|
+
const routes: Record<string, (req: unknown) => Promise<unknown>> = {};
|
|
35
|
+
const fakeFastify = {
|
|
36
|
+
get: (path: string, handler: (req: unknown) => Promise<unknown>) => {
|
|
37
|
+
routes[path] = handler;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await statusRoutes(fakeFastify as never, {});
|
|
42
|
+
|
|
43
|
+
const handler = routes['/api/status'];
|
|
44
|
+
expect(handler).toBeDefined();
|
|
45
|
+
|
|
46
|
+
const result = await handler({ accessMode: 'insider' });
|
|
47
|
+
const status = result as Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
expect(status).toHaveProperty('version');
|
|
50
|
+
expect(status).toHaveProperty('uptime');
|
|
51
|
+
expect(status.port).toBe(1934);
|
|
52
|
+
expect((status.chrome as { configured: boolean }).configured).toBe(true);
|
|
53
|
+
expect((status.auth as { insiderCount: number }).insiderCount).toBe(2);
|
|
54
|
+
expect((status.auth as { keyCount: number }).keyCount).toBe(1);
|
|
55
|
+
expect(status.events).toHaveLength(2);
|
|
56
|
+
expect(status.exportFormats).toEqual(['pdf', 'docx', 'zip']);
|
|
57
|
+
expect((status.diagrams as { mermaid: boolean }).mermaid).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects non-insider requests', async () => {
|
|
61
|
+
const routes: Record<string, (req: unknown) => Promise<unknown>> = {};
|
|
62
|
+
const fakeFastify = {
|
|
63
|
+
get: (path: string, handler: (req: unknown) => Promise<unknown>) => {
|
|
64
|
+
routes[path] = handler;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await statusRoutes(fakeFastify as never, {});
|
|
69
|
+
const result = await routes['/api/status']({ accessMode: 'outsider' });
|
|
70
|
+
expect(result).toEqual({ error: 'Insider auth required' });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server status endpoint — structured metadata for diagnostics and TOOLS.md generation.
|
|
3
|
+
*
|
|
4
|
+
* Returns version, uptime, port, connected services reachability,
|
|
5
|
+
* event schemas, insider count (no PII), and export capabilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
9
|
+
|
|
10
|
+
import { getConfig } from '../../config/index.js';
|
|
11
|
+
import { packageVersion } from '../../util/packageVersion.js';
|
|
12
|
+
|
|
13
|
+
const startTime = Date.now();
|
|
14
|
+
|
|
15
|
+
interface ServiceStatus {
|
|
16
|
+
url: string;
|
|
17
|
+
reachable: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function checkService(url: string): Promise<ServiceStatus> {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`${url}/status`, {
|
|
24
|
+
signal: AbortSignal.timeout(3000),
|
|
25
|
+
});
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
const data = (await res.json()) as { version?: string };
|
|
28
|
+
return { url, reachable: true, version: data.version };
|
|
29
|
+
}
|
|
30
|
+
return { url, reachable: false };
|
|
31
|
+
} catch {
|
|
32
|
+
return { url, reachable: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
37
|
+
export const statusRoutes: FastifyPluginAsync = async (fastify) => {
|
|
38
|
+
fastify.get('/api/status', async (request) => {
|
|
39
|
+
const config = getConfig();
|
|
40
|
+
|
|
41
|
+
// Only insiders get status
|
|
42
|
+
if (request.accessMode !== 'insider') {
|
|
43
|
+
return { error: 'Insider auth required' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [watcher, runner] = await Promise.all([
|
|
47
|
+
config.watcherUrl ? checkService(config.watcherUrl) : null,
|
|
48
|
+
config.runnerUrl ? checkService(config.runnerUrl) : null,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
version: packageVersion,
|
|
53
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
54
|
+
port: config.port,
|
|
55
|
+
chrome: {
|
|
56
|
+
configured: Boolean(config.chromePath),
|
|
57
|
+
path: config.chromePath,
|
|
58
|
+
},
|
|
59
|
+
auth: {
|
|
60
|
+
modes: config.authModes,
|
|
61
|
+
insiderCount: config.resolvedInsiders.length,
|
|
62
|
+
keyCount: config.resolvedKeys.length,
|
|
63
|
+
},
|
|
64
|
+
events: Object.entries(config.events).map(([name, schema]) => ({
|
|
65
|
+
name,
|
|
66
|
+
cmd: schema.cmd,
|
|
67
|
+
})),
|
|
68
|
+
exportFormats: ['pdf', 'docx', 'zip'],
|
|
69
|
+
diagrams: {
|
|
70
|
+
mermaid: true, // bundled via @mermaid-js/mermaid-cli
|
|
71
|
+
plantuml: {
|
|
72
|
+
localJar: Boolean(config.plantuml.jarPath),
|
|
73
|
+
servers: config.plantuml.servers,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
services: {
|
|
77
|
+
watcher,
|
|
78
|
+
runner,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth authentication routes.
|
|
3
|
+
*
|
|
4
|
+
* GET /auth/login - Redirect to Google consent screen
|
|
5
|
+
* GET /auth/callback - Handle OAuth callback, set session cookie
|
|
6
|
+
* GET /auth/logout - Clear session cookie
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
12
|
+
|
|
13
|
+
import { buildAuthUrl, exchangeCode, getUserInfo } from '../auth/google.js';
|
|
14
|
+
import { COOKIE_NAME, createSessionCookie } from '../auth/session.js';
|
|
15
|
+
import { getConfig, resetConfig } from '../config/index.js';
|
|
16
|
+
import { setInsiderKey } from '../util/state.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the redirect URI from the request.
|
|
20
|
+
* Uses the Host header to include port, and X-Forwarded-Proto for scheme.
|
|
21
|
+
*/
|
|
22
|
+
function getRedirectUri(request: {
|
|
23
|
+
headers: Record<string, string | string[] | undefined>;
|
|
24
|
+
hostname: string;
|
|
25
|
+
}): string {
|
|
26
|
+
const proto =
|
|
27
|
+
(request.headers['x-forwarded-proto'] as string | undefined) ?? 'http';
|
|
28
|
+
const host =
|
|
29
|
+
(request.headers['host'] as string | undefined) ?? request.hostname;
|
|
30
|
+
return `${proto}://${host}/auth/callback`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
34
|
+
export const authRoute: FastifyPluginAsync = async (fastify) => {
|
|
35
|
+
// GET /auth/login
|
|
36
|
+
fastify.get<{ Querystring: { returnTo?: string } }>(
|
|
37
|
+
'/auth/login',
|
|
38
|
+
async (request, reply) => {
|
|
39
|
+
const config = getConfig();
|
|
40
|
+
const google = config.googleAuth;
|
|
41
|
+
if (!google) {
|
|
42
|
+
return reply.code(500).send({ error: 'Google OAuth not configured' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const state = request.query.returnTo
|
|
46
|
+
? Buffer.from(request.query.returnTo).toString('base64url')
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
const url = buildAuthUrl(google.clientId, getRedirectUri(request), state);
|
|
50
|
+
return reply.redirect(url);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// GET /auth/callback
|
|
55
|
+
fastify.get<{
|
|
56
|
+
Querystring: { code?: string; error?: string; state?: string };
|
|
57
|
+
}>('/auth/callback', async (request, reply) => {
|
|
58
|
+
const config = getConfig();
|
|
59
|
+
const google = config.googleAuth;
|
|
60
|
+
const sessionSecret = config.sessionSecret;
|
|
61
|
+
|
|
62
|
+
if (!google || !sessionSecret) {
|
|
63
|
+
return reply.code(500).send({ error: 'Google OAuth not configured' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (request.query.error) {
|
|
67
|
+
return reply
|
|
68
|
+
.code(403)
|
|
69
|
+
.send({ error: `OAuth error: ${request.query.error}` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const code = request.query.code;
|
|
73
|
+
if (!code) {
|
|
74
|
+
return reply.code(400).send({ error: 'Missing authorization code' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Exchange code for tokens
|
|
78
|
+
const tokens = await exchangeCode(
|
|
79
|
+
google.clientId,
|
|
80
|
+
google.clientSecret,
|
|
81
|
+
getRedirectUri(request),
|
|
82
|
+
code,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Get user info
|
|
86
|
+
const userInfo = await getUserInfo(tokens.access_token);
|
|
87
|
+
if (!userInfo.email_verified) {
|
|
88
|
+
return reply.code(403).send({ error: 'Email not verified' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const email = userInfo.email.toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Check if user is a configured insider
|
|
94
|
+
const insider = config.resolvedInsiders.find(
|
|
95
|
+
(i) => i.email.toLowerCase() === email,
|
|
96
|
+
);
|
|
97
|
+
if (!insider) {
|
|
98
|
+
return reply.code(403).send({
|
|
99
|
+
error: 'Access denied. Your email is not authorized.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Auto-generate insider key on first login if missing
|
|
104
|
+
if (!insider.seed) {
|
|
105
|
+
const newSeed = crypto.randomBytes(32).toString('hex');
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
insider.seed = newSeed;
|
|
108
|
+
|
|
109
|
+
// Persist to state.json (mutable runtime state)
|
|
110
|
+
setInsiderKey(insider.email, newSeed, timestamp);
|
|
111
|
+
resetConfig(); // Reload to pick up new state
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Set session cookie
|
|
115
|
+
const cookieValue = createSessionCookie(
|
|
116
|
+
email,
|
|
117
|
+
sessionSecret,
|
|
118
|
+
userInfo.picture,
|
|
119
|
+
);
|
|
120
|
+
void reply.setCookie(COOKIE_NAME, cookieValue, {
|
|
121
|
+
path: '/',
|
|
122
|
+
httpOnly: true,
|
|
123
|
+
secure:
|
|
124
|
+
(request.headers['x-forwarded-proto'] as string | undefined) ===
|
|
125
|
+
'https',
|
|
126
|
+
sameSite: 'lax',
|
|
127
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Redirect to returnTo or root
|
|
131
|
+
const returnTo = request.query.state
|
|
132
|
+
? Buffer.from(request.query.state, 'base64url').toString()
|
|
133
|
+
: '/browse';
|
|
134
|
+
return reply.redirect(returnTo);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// GET /auth/logout
|
|
138
|
+
|
|
139
|
+
fastify.get('/auth/logout', async (_request, reply) => {
|
|
140
|
+
void reply.clearCookie(COOKIE_NAME, { path: '/' });
|
|
141
|
+
return reply.redirect('/');
|
|
142
|
+
});
|
|
143
|
+
};
|