@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.
Files changed (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. 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
+ };