@blackbelt-technology/pi-agent-dashboard 0.2.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 (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Fastify plugin that registers OAuth auth routes and the onRequest hook.
3
+ * Only registered when auth is configured.
4
+ */
5
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
6
+ import cookie from "@fastify/cookie";
7
+ import crypto from "node:crypto";
8
+ import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
9
+ import {
10
+ type ResolvedProvider,
11
+ type TokenPayload,
12
+ buildProviderRegistry,
13
+ ensureAuthSecret,
14
+ signToken,
15
+ verifyToken,
16
+ parseAuthCookie,
17
+ isUserAllowed,
18
+ buildRedirectUri,
19
+ buildAuthorizeUrl,
20
+ exchangeCode,
21
+ fetchUserInfo,
22
+ COOKIE_NAME,
23
+ } from "./auth.js";
24
+ import { isLoopback, isBypassedHost } from "./localhost-guard.js";
25
+
26
+ /**
27
+ * Returns true if the request URL matches any of the configured bypass prefixes.
28
+ * Exported for unit testing.
29
+ */
30
+ export function isBypassed(url: string, bypassUrls: string[]): boolean {
31
+ return bypassUrls.some((prefix) => url.startsWith(prefix));
32
+ }
33
+
34
+
35
+
36
+ /** Escape HTML special characters to prevent XSS in server-rendered pages. */
37
+ export function escapeHtml(str: string): string {
38
+ return str
39
+ .replace(/&/g, "&")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;")
42
+ .replace(/"/g, "&quot;")
43
+ .replace(/'/g, "&#39;");
44
+ }
45
+
46
+ export interface AuthPluginOptions {
47
+ authConfig: AuthConfig;
48
+ port: number;
49
+ /** Merged trusted networks (top-level + auth.bypassHosts) */
50
+ resolvedTrustedNetworks?: string[];
51
+ }
52
+
53
+ /**
54
+ * State parameter encoding: encodes the return URL + CSRF nonce.
55
+ */
56
+ function encodeState(returnUrl: string): string {
57
+ const nonce = crypto.randomBytes(8).toString("hex");
58
+ return Buffer.from(JSON.stringify({ returnUrl, nonce })).toString("base64url");
59
+ }
60
+
61
+ function decodeState(state: string): { returnUrl: string } {
62
+ try {
63
+ const parsed = JSON.parse(Buffer.from(state, "base64url").toString());
64
+ return { returnUrl: parsed.returnUrl || "/" };
65
+ } catch {
66
+ return { returnUrl: "/" };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Simple login page HTML with provider links.
72
+ */
73
+ function renderLoginPage(providers: ResolvedProvider[], error?: string): string {
74
+ const providerLinks = providers
75
+ .map((p) => `<a href="/auth/start/${p.key}" style="display:block;margin:10px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;text-align:center;font-size:16px;">Sign in with ${p.name}</a>`)
76
+ .join("\n");
77
+
78
+ const errorHtml = error
79
+ ? `<div style="color:#ef4444;margin-bottom:16px;">${error}</div>`
80
+ : "";
81
+
82
+ return `<!DOCTYPE html>
83
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
84
+ <title>PI Dashboard — Sign In</title>
85
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0;}
86
+ .card{background:#1e293b;padding:40px;border-radius:12px;max-width:400px;width:100%;text-align:center;}
87
+ h1{margin:0 0 24px;font-size:24px;}</style>
88
+ </head><body><div class="card"><h1>🔐 PI Dashboard</h1>${errorHtml}${providerLinks}</div></body></html>`;
89
+ }
90
+
91
+ /**
92
+ * Access denied page HTML.
93
+ */
94
+ function renderDeniedPage(email: string): string {
95
+ const safeEmail = escapeHtml(email);
96
+ return `<!DOCTYPE html>
97
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
98
+ <title>PI Dashboard — Access Denied</title>
99
+ <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0;}
100
+ .card{background:#1e293b;padding:40px;border-radius:12px;max-width:400px;width:100%;text-align:center;}
101
+ h1{margin:0 0 16px;font-size:24px;color:#ef4444;}</style>
102
+ </head><body><div class="card"><h1>Access Denied</h1><p>The email <strong>${safeEmail}</strong> is not authorized to access this dashboard.</p>
103
+ <a href="/auth/login" style="color:#60a5fa;">Try a different account</a></div></body></html>`;
104
+ }
105
+
106
+ export async function registerAuthPlugin(
107
+ fastify: FastifyInstance,
108
+ options: AuthPluginOptions,
109
+ ): Promise<void> {
110
+ const { authConfig, port, resolvedTrustedNetworks } = options;
111
+
112
+ // Mutable auth state — can be rebuilt at runtime via reloadAuth()
113
+ const authState = {
114
+ secret: ensureAuthSecret(authConfig),
115
+ providerRegistry: await buildProviderRegistry(authConfig.providers),
116
+ allowedUsers: authConfig.allowedUsers,
117
+ bypassUrls: authConfig.bypassUrls ?? [],
118
+ bypassHosts: resolvedTrustedNetworks ?? authConfig.bypassHosts ?? [],
119
+ };
120
+
121
+ if (authState.providerRegistry.size === 0) {
122
+ console.warn("Auth configured but no providers resolved — auth disabled");
123
+ return;
124
+ }
125
+
126
+ // Expose reload function on the fastify instance for runtime config updates
127
+ (fastify as any)._reloadAuth = async (newConfig: AuthConfig) => {
128
+ authState.secret = ensureAuthSecret(newConfig);
129
+ authState.providerRegistry = await buildProviderRegistry(newConfig.providers);
130
+ authState.allowedUsers = newConfig.allowedUsers;
131
+ authState.bypassUrls = newConfig.bypassUrls ?? [];
132
+ authState.bypassHosts = newConfig.bypassHosts ?? [];
133
+ const names = Array.from(authState.providerRegistry.values()).map((p) => p.name);
134
+ console.log(`🔐 Auth reloaded with providers: ${names.join(", ")}`);
135
+ };
136
+
137
+ // Tag requests with authentication status (read by createNetworkGuard)
138
+ fastify.decorateRequest("isAuthenticated", false);
139
+
140
+ // Register cookie plugin
141
+ await fastify.register(cookie);
142
+
143
+ // ─── Auth Routes ────────────────────────────────────────────────────────
144
+
145
+ // GET /auth/login — provider picker or auto-redirect
146
+ fastify.get("/auth/login", async (request, reply) => {
147
+ const providers = Array.from(authState.providerRegistry.values());
148
+ const error = (request.query as any)?.error;
149
+
150
+ if (providers.length === 1 && !error) {
151
+ // Auto-redirect to single provider
152
+ const p = providers[0];
153
+ const redirectUri = buildRedirectUri(p.key, port);
154
+ const returnUrl = (request.query as any)?.return || "/";
155
+ const state = encodeState(returnUrl);
156
+ const url = buildAuthorizeUrl(p, redirectUri, state);
157
+ return reply.redirect(url);
158
+ }
159
+
160
+ return reply.type("text/html").send(renderLoginPage(providers, error));
161
+ });
162
+
163
+ // GET /auth/start/:provider — redirect to provider's authorize URL
164
+ fastify.get("/auth/start/:provider", async (request, reply) => {
165
+ const providerKey = (request.params as any).provider;
166
+ const provider = authState.providerRegistry.get(providerKey);
167
+ if (!provider) {
168
+ return reply.code(404).send({ error: "Unknown provider" });
169
+ }
170
+ const redirectUri = buildRedirectUri(providerKey, port);
171
+ const returnUrl = (request.query as any)?.return || "/";
172
+ const state = encodeState(returnUrl);
173
+ const url = buildAuthorizeUrl(provider, redirectUri, state);
174
+ return reply.redirect(url);
175
+ });
176
+
177
+ // GET /auth/callback/:provider — OAuth callback
178
+ fastify.get("/auth/callback/:provider", async (request, reply) => {
179
+ const providerKey = (request.params as any).provider;
180
+ const provider = authState.providerRegistry.get(providerKey);
181
+ if (!provider) {
182
+ return reply.code(404).send({ error: "Unknown provider" });
183
+ }
184
+
185
+ const query = request.query as any;
186
+ const code = query.code;
187
+ const stateParam = query.state || "";
188
+
189
+ if (!code) {
190
+ return reply.redirect("/auth/login?error=Missing+authorization+code");
191
+ }
192
+
193
+ const redirectUri = buildRedirectUri(providerKey, port);
194
+ const accessToken = await exchangeCode(provider, code, redirectUri);
195
+ if (!accessToken) {
196
+ return reply.redirect("/auth/login?error=Token+exchange+failed");
197
+ }
198
+
199
+ const userInfo = await fetchUserInfo(provider, accessToken);
200
+ if (!userInfo) {
201
+ return reply.redirect("/auth/login?error=Failed+to+fetch+user+info");
202
+ }
203
+
204
+ if (!isUserAllowed(userInfo.email, userInfo.username, authState.allowedUsers)) {
205
+ return reply.code(403).type("text/html").send(renderDeniedPage(userInfo.email));
206
+ }
207
+
208
+ const token = signToken(
209
+ { sub: userInfo.email, name: userInfo.name, username: userInfo.username, provider: providerKey },
210
+ authState.secret,
211
+ );
212
+
213
+ const { returnUrl } = decodeState(stateParam);
214
+
215
+ reply.setCookie(COOKIE_NAME, token, {
216
+ path: "/",
217
+ httpOnly: true,
218
+ secure: request.protocol === "https",
219
+ sameSite: "lax",
220
+ maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
221
+ });
222
+
223
+ return reply.redirect(returnUrl);
224
+ });
225
+
226
+ // POST /auth/logout
227
+ fastify.post("/auth/logout", async (_request, reply) => {
228
+ reply.clearCookie(COOKIE_NAME, { path: "/" });
229
+ return reply.redirect("/auth/login");
230
+ });
231
+
232
+ // GET /auth/status — no auth required
233
+ fastify.get("/auth/status", async (request, reply) => {
234
+ const cookieToken = (request.cookies as any)?.[COOKIE_NAME];
235
+ if (cookieToken) {
236
+ const payload = verifyToken(cookieToken, authState.secret);
237
+ if (payload) {
238
+ return { authenticated: true, user: { name: payload.name, email: payload.sub, provider: payload.provider } };
239
+ }
240
+ }
241
+ return { authenticated: false };
242
+ });
243
+
244
+ // ─── onRequest Hook ─────────────────────────────────────────────────────
245
+
246
+ fastify.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) => {
247
+ // Localhost bypass
248
+ if (isLoopback(request.ip)) return;
249
+
250
+ // Skip auth routes
251
+ if (request.url.startsWith("/auth/")) return;
252
+
253
+ // Skip health endpoint
254
+ if (request.url === "/api/health") return;
255
+
256
+ // Skip configured bypass URL prefixes
257
+ if (isBypassed(request.url, authState.bypassUrls)) return;
258
+
259
+ // Skip configured bypass hosts (trusted source IPs)
260
+ if (isBypassedHost(request.ip, authState.bypassHosts)) return;
261
+
262
+ // Validate JWT cookie
263
+ const cookieToken = (request.cookies as any)?.[COOKIE_NAME];
264
+ if (cookieToken) {
265
+ const payload = verifyToken(cookieToken, authState.secret);
266
+ if (payload) {
267
+ (request as any).isAuthenticated = true;
268
+ return;
269
+ }
270
+ // Invalid/expired — clear cookie
271
+ reply.clearCookie(COOKIE_NAME, { path: "/" });
272
+ }
273
+
274
+ // Not authenticated — redirect or 401
275
+ const accept = request.headers.accept || "";
276
+ if (accept.includes("text/html")) {
277
+ const returnUrl = encodeURIComponent(request.url);
278
+ return reply.redirect(`/auth/login?return=${returnUrl}`);
279
+ }
280
+ return reply.code(401).send({ error: "Authentication required" });
281
+ });
282
+
283
+ const providerNames = Array.from(authState.providerRegistry.values()).map((p) => p.name);
284
+ console.log(`🔐 Auth enabled with providers: ${providerNames.join(", ")}`);
285
+ }
286
+
287
+ /**
288
+ * Validate auth for a WebSocket upgrade request.
289
+ * Returns true if the request is allowed, false if it should be rejected.
290
+ */
291
+ export function validateWsUpgrade(
292
+ cookieHeader: string | undefined,
293
+ remoteAddress: string,
294
+ secret: string,
295
+ trustedNetworks: string[] = [],
296
+ ): boolean {
297
+ if (isLoopback(remoteAddress)) return true;
298
+ if (trustedNetworks.length > 0 && isBypassedHost(remoteAddress, trustedNetworks)) return true;
299
+ const token = parseAuthCookie(cookieHeader);
300
+ if (!token) return false;
301
+ return verifyToken(token, secret) !== null;
302
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * OAuth2 authentication module for the dashboard server.
3
+ * Supports GitHub, Google, Keycloak, and generic OIDC providers.
4
+ */
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import jwt from "jsonwebtoken";
8
+ import type { AuthConfig, AuthProviderConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
9
+ import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
10
+ import { getTunnelUrl } from "./tunnel.js";
11
+
12
+ // ─── Types ───────────────────────────────────────────────────────────────────
13
+
14
+ export interface ResolvedProvider {
15
+ key: string;
16
+ name: string;
17
+ authorizeUrl: string;
18
+ tokenUrl: string;
19
+ userInfoUrl: string;
20
+ scopes: string;
21
+ clientId: string;
22
+ clientSecret: string;
23
+ }
24
+
25
+ export interface AuthUser {
26
+ sub: string; // email
27
+ name: string;
28
+ username: string;
29
+ provider: string;
30
+ }
31
+
32
+ export interface TokenPayload extends AuthUser {
33
+ exp: number;
34
+ }
35
+
36
+ // ─── Built-in provider endpoints ─────────────────────────────────────────────
37
+
38
+ const GITHUB_ENDPOINTS = {
39
+ authorizeUrl: "https://github.com/login/oauth/authorize",
40
+ tokenUrl: "https://github.com/login/oauth/access_token",
41
+ userInfoUrl: "https://api.github.com/user",
42
+ scopes: "user:email",
43
+ };
44
+
45
+ const GOOGLE_ISSUER = "https://accounts.google.com";
46
+
47
+ // ─── OIDC Discovery ─────────────────────────────────────────────────────────
48
+
49
+ interface OIDCDiscovery {
50
+ authorization_endpoint: string;
51
+ token_endpoint: string;
52
+ userinfo_endpoint: string;
53
+ }
54
+
55
+ export async function fetchOIDCDiscovery(issuerUrl: string): Promise<OIDCDiscovery> {
56
+ const url = `${issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
57
+ const res = await fetch(url);
58
+ if (!res.ok) {
59
+ throw new Error(`OIDC discovery failed for ${issuerUrl}: ${res.status}`);
60
+ }
61
+ const data = await res.json();
62
+ return {
63
+ authorization_endpoint: data.authorization_endpoint,
64
+ token_endpoint: data.token_endpoint,
65
+ userinfo_endpoint: data.userinfo_endpoint,
66
+ };
67
+ }
68
+
69
+ // ─── Provider Registry ──────────────────────────────────────────────────────
70
+
71
+ export async function buildProviderRegistry(
72
+ providers: Record<string, AuthProviderConfig>,
73
+ ): Promise<Map<string, ResolvedProvider>> {
74
+ const registry = new Map<string, ResolvedProvider>();
75
+
76
+ for (const [key, config] of Object.entries(providers)) {
77
+ try {
78
+ const resolved = await resolveProvider(key, config);
79
+ if (resolved) registry.set(key, resolved);
80
+ } catch (err: any) {
81
+ console.warn(`Failed to resolve OAuth provider "${key}": ${err.message}`);
82
+ }
83
+ }
84
+
85
+ return registry;
86
+ }
87
+
88
+ async function resolveProvider(
89
+ key: string,
90
+ config: AuthProviderConfig,
91
+ ): Promise<ResolvedProvider | null> {
92
+ const base = { key, clientId: config.clientId, clientSecret: config.clientSecret };
93
+
94
+ if (key === "github") {
95
+ return {
96
+ ...base,
97
+ name: config.name ?? "GitHub",
98
+ ...GITHUB_ENDPOINTS,
99
+ };
100
+ }
101
+
102
+ // Google, Keycloak, or generic OIDC — all use OIDC discovery
103
+ const issuerUrl = key === "google" ? GOOGLE_ISSUER : config.issuerUrl;
104
+ if (!issuerUrl) {
105
+ console.warn(`OAuth provider "${key}" requires issuerUrl`);
106
+ return null;
107
+ }
108
+
109
+ const discovery = await fetchOIDCDiscovery(issuerUrl);
110
+ const defaultNames: Record<string, string> = {
111
+ google: "Google",
112
+ keycloak: "Keycloak",
113
+ oidc: "OIDC",
114
+ };
115
+
116
+ return {
117
+ ...base,
118
+ name: config.name ?? defaultNames[key] ?? key,
119
+ authorizeUrl: discovery.authorization_endpoint,
120
+ tokenUrl: discovery.token_endpoint,
121
+ userInfoUrl: discovery.userinfo_endpoint,
122
+ scopes: "openid email profile",
123
+ };
124
+ }
125
+
126
+ // ─── Auth Secret Management ─────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Ensure the auth config has a secret. If missing, generate one and persist.
130
+ * Returns the secret string.
131
+ */
132
+ export function ensureAuthSecret(authConfig: AuthConfig): string {
133
+ if (authConfig.secret) return authConfig.secret;
134
+
135
+ const secret = crypto.randomBytes(16).toString("hex"); // 32-char hex
136
+ authConfig.secret = secret;
137
+
138
+ // Persist back to config file
139
+ try {
140
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
141
+ const parsed = JSON.parse(raw);
142
+ if (parsed.auth) {
143
+ parsed.auth.secret = secret;
144
+ }
145
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(parsed, null, 2) + "\n");
146
+ } catch (err: any) {
147
+ console.warn(`Failed to persist auth secret: ${err.message}`);
148
+ }
149
+
150
+ return secret;
151
+ }
152
+
153
+ // ─── JWT Token Helpers ──────────────────────────────────────────────────────
154
+
155
+ const TOKEN_EXPIRY = "7d";
156
+ export const COOKIE_NAME = "pi_dash_token";
157
+
158
+ export function signToken(user: AuthUser, secret: string): string {
159
+ return jwt.sign(
160
+ { sub: user.sub, name: user.name, username: user.username, provider: user.provider },
161
+ secret,
162
+ { expiresIn: TOKEN_EXPIRY },
163
+ );
164
+ }
165
+
166
+ export function verifyToken(token: string, secret: string): TokenPayload | null {
167
+ try {
168
+ const payload = jwt.verify(token, secret) as TokenPayload;
169
+ return payload;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ // ─── Cookie Parsing ─────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Parse the auth token from a raw cookie header string.
179
+ */
180
+ export function parseAuthCookie(cookieHeader: string | undefined): string | null {
181
+ if (!cookieHeader) return null;
182
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]*)`));
183
+ return match ? match[1] : null;
184
+ }
185
+
186
+ // ─── Email Allowlist ────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Check if an email is allowed by the allowedEmails list.
190
+ * Supports exact matches and domain wildcards (*@domain.com).
191
+ * Returns true if no allowedEmails is configured (allow all).
192
+ */
193
+ /**
194
+ * Check if a user is allowed by the allowedUsers list.
195
+ * Matches against email, username, or domain wildcards (*@domain.com).
196
+ * Returns true if no allowedUsers is configured (allow all).
197
+ */
198
+ export function isUserAllowed(email: string, username: string, allowedUsers?: string[]): boolean {
199
+ if (!allowedUsers || allowedUsers.length === 0) return true;
200
+ const lowerEmail = email.toLowerCase();
201
+ const lowerUsername = username.toLowerCase();
202
+ return allowedUsers.some((pattern) => {
203
+ const p = pattern.toLowerCase();
204
+ if (p.startsWith("*@")) {
205
+ const domain = p.slice(1); // "@domain.com"
206
+ return lowerEmail.endsWith(domain);
207
+ }
208
+ // Match against email or username
209
+ return lowerEmail === p || lowerUsername === p;
210
+ });
211
+ }
212
+
213
+ // ─── Redirect URI Builder ───────────────────────────────────────────────────
214
+
215
+ export function buildRedirectUri(provider: string, port: number): string {
216
+ const base = getTunnelUrl() ?? `http://localhost:${port}`;
217
+ return `${base}/auth/callback/${provider}`;
218
+ }
219
+
220
+ // ─── OAuth Flow Helpers ─────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Build the authorize URL to redirect the user to.
224
+ */
225
+ export function buildAuthorizeUrl(
226
+ provider: ResolvedProvider,
227
+ redirectUri: string,
228
+ state: string,
229
+ ): string {
230
+ const params = new URLSearchParams({
231
+ client_id: provider.clientId,
232
+ redirect_uri: redirectUri,
233
+ scope: provider.scopes,
234
+ state,
235
+ response_type: "code",
236
+ });
237
+ return `${provider.authorizeUrl}?${params.toString()}`;
238
+ }
239
+
240
+ /**
241
+ * Exchange an authorization code for an access token.
242
+ */
243
+ export async function exchangeCode(
244
+ provider: ResolvedProvider,
245
+ code: string,
246
+ redirectUri: string,
247
+ ): Promise<string | null> {
248
+ try {
249
+ const body = new URLSearchParams({
250
+ client_id: provider.clientId,
251
+ client_secret: provider.clientSecret,
252
+ code,
253
+ redirect_uri: redirectUri,
254
+ grant_type: "authorization_code",
255
+ });
256
+
257
+ const headers: Record<string, string> = {
258
+ "Content-Type": "application/x-www-form-urlencoded",
259
+ };
260
+ // GitHub needs Accept header to get JSON response
261
+ if (provider.key === "github") {
262
+ headers["Accept"] = "application/json";
263
+ }
264
+
265
+ const res = await fetch(provider.tokenUrl, {
266
+ method: "POST",
267
+ headers,
268
+ body: body.toString(),
269
+ });
270
+
271
+ if (!res.ok) return null;
272
+
273
+ const data = await res.json();
274
+ return data.access_token ?? null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Fetch user info from the provider using an access token.
282
+ * Returns { email, name } or null on failure.
283
+ */
284
+ export async function fetchUserInfo(
285
+ provider: ResolvedProvider,
286
+ accessToken: string,
287
+ ): Promise<{ email: string; name: string; username: string } | null> {
288
+ try {
289
+ const res = await fetch(provider.userInfoUrl, {
290
+ headers: { Authorization: `Bearer ${accessToken}` },
291
+ });
292
+ if (!res.ok) return null;
293
+
294
+ const data = await res.json();
295
+
296
+ if (provider.key === "github") {
297
+ // GitHub: name may be null, email may be null (private)
298
+ const name = data.name || data.login || "Unknown";
299
+ let email = data.email;
300
+ if (!email) {
301
+ // Fetch email from /user/emails endpoint
302
+ const emailRes = await fetch("https://api.github.com/user/emails", {
303
+ headers: { Authorization: `Bearer ${accessToken}` },
304
+ });
305
+ if (emailRes.ok) {
306
+ const emails = await emailRes.json();
307
+ const primary = emails.find((e: any) => e.primary) ?? emails[0];
308
+ email = primary?.email;
309
+ }
310
+ }
311
+ const username = data.login || "";
312
+ return email ? { email, name, username } : null;
313
+ }
314
+
315
+ // OIDC providers: standard claims
316
+ const email = data.email;
317
+ const name = data.name || data.preferred_username || data.sub || "Unknown";
318
+ const username = data.preferred_username || data.sub || "";
319
+ return email ? { email, name, username } : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Directory browsing logic for the browse API endpoint.
3
+ */
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import type { BrowseEntry, BrowseResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
8
+
9
+ const MAX_ENTRIES = 200;
10
+
11
+ /**
12
+ * List subdirectories of a given path.
13
+ * Excludes hidden directories (starting with ".").
14
+ * Detects .git and .pi subdirectories for visual hints.
15
+ * Caps at 200 entries, sorted alphabetically.
16
+ */
17
+ export async function listDirectories(dirPath?: string): Promise<BrowseResult> {
18
+ const resolved = dirPath ?? os.homedir();
19
+
20
+ // Verify the directory exists and is a directory
21
+ const stat = await fs.stat(resolved);
22
+ if (!stat.isDirectory()) {
23
+ throw new Error("not a directory");
24
+ }
25
+
26
+ const rawEntries = await fs.readdir(resolved, { withFileTypes: true });
27
+
28
+ // Filter: directories only, no hidden dirs
29
+ const dirs = rawEntries.filter(
30
+ (e) => e.isDirectory() && !e.name.startsWith(".")
31
+ );
32
+
33
+ // Sort alphabetically
34
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
35
+
36
+ // Cap at MAX_ENTRIES
37
+ const capped = dirs.slice(0, MAX_ENTRIES);
38
+
39
+ // Build entries with isGit/isPi detection
40
+ const entries: BrowseEntry[] = await Promise.all(
41
+ capped.map(async (d) => {
42
+ const fullPath = path.join(resolved, d.name);
43
+ const [isGit, isPi] = await Promise.all([
44
+ fs.access(path.join(fullPath, ".git")).then(() => true, () => false),
45
+ fs.access(path.join(fullPath, ".pi")).then(() => true, () => false),
46
+ ]);
47
+ return { name: d.name, path: fullPath, isGit, isPi };
48
+ })
49
+ );
50
+
51
+ // Parent: null for root
52
+ const parent = resolved === "/" ? null : path.dirname(resolved);
53
+
54
+ return { entries, parent, current: resolved };
55
+ }