@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.
- package/AGENTS.md +342 -0
- package/README.md +619 -0
- package/docs/architecture.md +646 -0
- package/package.json +92 -0
- package/packages/extension/package.json +33 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
- package/packages/extension/src/__tests__/connection.test.ts +344 -0
- package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
- package/packages/extension/src/__tests__/git-info.test.ts +112 -0
- package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
- package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
- package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
- package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
- package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
- package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
- package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
- package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
- package/packages/extension/src/ask-user-tool.ts +63 -0
- package/packages/extension/src/bridge-context.ts +64 -0
- package/packages/extension/src/bridge.ts +926 -0
- package/packages/extension/src/command-handler.ts +538 -0
- package/packages/extension/src/connection.ts +204 -0
- package/packages/extension/src/dev-build.ts +39 -0
- package/packages/extension/src/event-forwarder.ts +40 -0
- package/packages/extension/src/flow-event-wiring.ts +102 -0
- package/packages/extension/src/git-info.ts +65 -0
- package/packages/extension/src/git-link-builder.ts +112 -0
- package/packages/extension/src/model-tracker.ts +56 -0
- package/packages/extension/src/pi-env.d.ts +23 -0
- package/packages/extension/src/process-metrics.ts +70 -0
- package/packages/extension/src/process-scanner.ts +396 -0
- package/packages/extension/src/prompt-expander.ts +87 -0
- package/packages/extension/src/provider-register.ts +276 -0
- package/packages/extension/src/server-auto-start.ts +87 -0
- package/packages/extension/src/server-launcher.ts +82 -0
- package/packages/extension/src/server-probe.ts +33 -0
- package/packages/extension/src/session-sync.ts +154 -0
- package/packages/extension/src/source-detector.ts +26 -0
- package/packages/extension/src/ui-proxy.ts +269 -0
- package/packages/extension/tsconfig.json +11 -0
- package/packages/server/package.json +37 -0
- package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
- package/packages/server/src/__tests__/auth.test.ts +224 -0
- package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
- package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
- package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
- package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
- package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
- package/packages/server/src/__tests__/config-api.test.ts +104 -0
- package/packages/server/src/__tests__/cors.test.ts +48 -0
- package/packages/server/src/__tests__/directory-service.test.ts +240 -0
- package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
- package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
- package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
- package/packages/server/src/__tests__/extension-register.test.ts +61 -0
- package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
- package/packages/server/src/__tests__/git-operations.test.ts +251 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
- package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
- package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
- package/packages/server/src/__tests__/json-store.test.ts +70 -0
- package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
- package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
- package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
- package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
- package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
- package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
- package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
- package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
- package/packages/server/src/__tests__/package-routes.test.ts +172 -0
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
- package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
- package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
- package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
- package/packages/server/src/__tests__/process-manager.test.ts +184 -0
- package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
- package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
- package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
- package/packages/server/src/__tests__/server-pid.test.ts +89 -0
- package/packages/server/src/__tests__/session-api.test.ts +244 -0
- package/packages/server/src/__tests__/session-diff.test.ts +138 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
- package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
- package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
- package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
- package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
- package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
- package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
- package/packages/server/src/__tests__/tunnel.test.ts +206 -0
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
- package/packages/server/src/auth-plugin.ts +302 -0
- package/packages/server/src/auth.ts +323 -0
- package/packages/server/src/browse.ts +55 -0
- package/packages/server/src/browser-gateway.ts +495 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
- package/packages/server/src/browser-handlers/handler-context.ts +45 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
- package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
- package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
- package/packages/server/src/cli.ts +347 -0
- package/packages/server/src/config-api.ts +130 -0
- package/packages/server/src/directory-service.ts +162 -0
- package/packages/server/src/editor-detection.ts +60 -0
- package/packages/server/src/editor-manager.ts +352 -0
- package/packages/server/src/editor-proxy.ts +134 -0
- package/packages/server/src/editor-registry.ts +108 -0
- package/packages/server/src/event-status-extraction.ts +131 -0
- package/packages/server/src/event-wiring.ts +589 -0
- package/packages/server/src/extension-register.ts +92 -0
- package/packages/server/src/git-operations.ts +200 -0
- package/packages/server/src/headless-pid-registry.ts +207 -0
- package/packages/server/src/idle-timer.ts +61 -0
- package/packages/server/src/json-store.ts +32 -0
- package/packages/server/src/localhost-guard.ts +117 -0
- package/packages/server/src/memory-event-store.ts +193 -0
- package/packages/server/src/memory-session-manager.ts +123 -0
- package/packages/server/src/meta-persistence.ts +64 -0
- package/packages/server/src/migrate-persistence.ts +195 -0
- package/packages/server/src/npm-search-proxy.ts +143 -0
- package/packages/server/src/oauth-callback-server.ts +177 -0
- package/packages/server/src/openspec-archive.ts +60 -0
- package/packages/server/src/package-manager-wrapper.ts +200 -0
- package/packages/server/src/pending-fork-registry.ts +53 -0
- package/packages/server/src/pending-load-manager.ts +110 -0
- package/packages/server/src/pending-resume-registry.ts +69 -0
- package/packages/server/src/pi-gateway.ts +419 -0
- package/packages/server/src/pi-resource-scanner.ts +369 -0
- package/packages/server/src/preferences-store.ts +116 -0
- package/packages/server/src/process-manager.ts +311 -0
- package/packages/server/src/provider-auth-handlers.ts +438 -0
- package/packages/server/src/provider-auth-storage.ts +200 -0
- package/packages/server/src/resolve-path.ts +12 -0
- package/packages/server/src/routes/editor-routes.ts +86 -0
- package/packages/server/src/routes/file-routes.ts +116 -0
- package/packages/server/src/routes/git-routes.ts +89 -0
- package/packages/server/src/routes/openspec-routes.ts +99 -0
- package/packages/server/src/routes/package-routes.ts +172 -0
- package/packages/server/src/routes/provider-auth-routes.ts +244 -0
- package/packages/server/src/routes/provider-routes.ts +101 -0
- package/packages/server/src/routes/route-deps.ts +23 -0
- package/packages/server/src/routes/session-routes.ts +91 -0
- package/packages/server/src/routes/system-routes.ts +271 -0
- package/packages/server/src/server-pid.ts +84 -0
- package/packages/server/src/server.ts +554 -0
- package/packages/server/src/session-api.ts +330 -0
- package/packages/server/src/session-bootstrap.ts +80 -0
- package/packages/server/src/session-diff.ts +178 -0
- package/packages/server/src/session-discovery.ts +134 -0
- package/packages/server/src/session-file-reader.ts +135 -0
- package/packages/server/src/session-order-manager.ts +73 -0
- package/packages/server/src/session-scanner.ts +233 -0
- package/packages/server/src/session-stats-reader.ts +99 -0
- package/packages/server/src/terminal-gateway.ts +51 -0
- package/packages/server/src/terminal-manager.ts +241 -0
- package/packages/server/src/tunnel.ts +329 -0
- package/packages/server/tsconfig.json +11 -0
- package/packages/shared/package.json +15 -0
- package/packages/shared/src/__tests__/config.test.ts +358 -0
- package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
- package/packages/shared/src/__tests__/protocol.test.ts +243 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
- package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
- package/packages/shared/src/archive-types.ts +11 -0
- package/packages/shared/src/browser-protocol.ts +534 -0
- package/packages/shared/src/config.ts +245 -0
- package/packages/shared/src/diff-types.ts +41 -0
- package/packages/shared/src/editor-types.ts +18 -0
- package/packages/shared/src/mdns-discovery.ts +248 -0
- package/packages/shared/src/openspec-activity-detector.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +96 -0
- package/packages/shared/src/protocol.ts +369 -0
- package/packages/shared/src/resolve-jiti.ts +43 -0
- package/packages/shared/src/rest-api.ts +255 -0
- package/packages/shared/src/server-identity.ts +51 -0
- package/packages/shared/src/session-meta.ts +86 -0
- package/packages/shared/src/state-replay.ts +174 -0
- package/packages/shared/src/stats-extractor.ts +54 -0
- package/packages/shared/src/terminal-types.ts +18 -0
- package/packages/shared/src/types.ts +351 -0
- 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, "<")
|
|
41
|
+
.replace(/>/g, ">")
|
|
42
|
+
.replace(/"/g, """)
|
|
43
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|