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