@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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Proxy for the dashboard bridge extension.
|
|
3
|
+
*
|
|
4
|
+
* Wraps ctx.ui dialog methods (confirm, select, input, editor) to forward
|
|
5
|
+
* them to the dashboard server. For TUI sessions, races the original method
|
|
6
|
+
* against the dashboard response. For headless sessions, only the dashboard
|
|
7
|
+
* can respond.
|
|
8
|
+
*
|
|
9
|
+
* Fire-and-forget methods (notify) are forwarded alongside the original call.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionUiResponseMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
13
|
+
|
|
14
|
+
export interface UiProxyOptions {
|
|
15
|
+
/** The original ctx.ui object to wrap */
|
|
16
|
+
ui: {
|
|
17
|
+
confirm(title: string, message: string, opts?: any): Promise<boolean>;
|
|
18
|
+
select(title: string, options: string[], opts?: any): Promise<string | undefined>;
|
|
19
|
+
input(title: string, placeholder?: string, opts?: any): Promise<string | undefined>;
|
|
20
|
+
editor?(title: string, prefill?: string, opts?: any): Promise<string | undefined>;
|
|
21
|
+
notify(message: string, type?: string): void;
|
|
22
|
+
};
|
|
23
|
+
/** Whether TUI is available (race mode vs dashboard-only) */
|
|
24
|
+
hasUI: boolean;
|
|
25
|
+
/** Get current session ID */
|
|
26
|
+
getSessionId: () => string;
|
|
27
|
+
/** Send a message to the dashboard server */
|
|
28
|
+
send: (msg: any) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PendingRequest {
|
|
32
|
+
method: string;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
resolve: (value: any) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createUiProxy(options: UiProxyOptions) {
|
|
38
|
+
const { ui, hasUI, getSessionId, send } = options;
|
|
39
|
+
const pending = new Map<string, PendingRequest>();
|
|
40
|
+
|
|
41
|
+
// Capture original method references BEFORE ctx.ui is patched in-place.
|
|
42
|
+
// Without this, the proxy's call to ui.notify() would recurse into itself
|
|
43
|
+
// because bridge.ts overwrites ctx.ui.notify with the proxy's own method.
|
|
44
|
+
const originalConfirm = ui.confirm.bind(ui);
|
|
45
|
+
const originalSelect = ui.select.bind(ui);
|
|
46
|
+
const originalInput = ui.input.bind(ui);
|
|
47
|
+
const originalEditor = ui.editor?.bind(ui);
|
|
48
|
+
const originalNotify = ui.notify.bind(ui);
|
|
49
|
+
|
|
50
|
+
function generateRequestId(): string {
|
|
51
|
+
return crypto.randomUUID();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendRequest(method: string, params: Record<string, unknown>): string {
|
|
55
|
+
const requestId = generateRequestId();
|
|
56
|
+
send({
|
|
57
|
+
type: "extension_ui_request",
|
|
58
|
+
sessionId: getSessionId(),
|
|
59
|
+
requestId,
|
|
60
|
+
method,
|
|
61
|
+
params,
|
|
62
|
+
});
|
|
63
|
+
return requestId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createDashboardPromise<T>(requestId: string, method: string, params: Record<string, unknown>): Promise<T> {
|
|
67
|
+
return new Promise<T>((resolve) => {
|
|
68
|
+
pending.set(requestId, { method, params, resolve });
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Re-send all pending UI requests (e.g. after server reconnect) */
|
|
73
|
+
function resendPending(): void {
|
|
74
|
+
for (const [requestId, entry] of pending) {
|
|
75
|
+
send({
|
|
76
|
+
type: "extension_ui_request",
|
|
77
|
+
sessionId: getSessionId(),
|
|
78
|
+
requestId,
|
|
79
|
+
method: entry.method,
|
|
80
|
+
params: entry.params,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extract the result for a specific dialog method from the response */
|
|
86
|
+
function extractResult(method: string, response: ExtensionUiResponseMessage): any {
|
|
87
|
+
if (response.cancelled) {
|
|
88
|
+
switch (method) {
|
|
89
|
+
case "confirm":
|
|
90
|
+
return false;
|
|
91
|
+
case "multiselect":
|
|
92
|
+
return [];
|
|
93
|
+
default:
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = response.result as Record<string, unknown> | undefined;
|
|
99
|
+
switch (method) {
|
|
100
|
+
case "confirm":
|
|
101
|
+
return result?.confirmed ?? false;
|
|
102
|
+
case "select":
|
|
103
|
+
case "input":
|
|
104
|
+
case "editor":
|
|
105
|
+
return result?.value;
|
|
106
|
+
case "multiselect":
|
|
107
|
+
return (result?.values as string[]) ?? [];
|
|
108
|
+
default:
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Recursion guard: if ui.confirm/select/etc is actually our own proxy
|
|
114
|
+
// (e.g. ctx.ui was already patched from a previous /reload), skip the
|
|
115
|
+
// TUI race to avoid infinite recursion.
|
|
116
|
+
let inProxy = false;
|
|
117
|
+
|
|
118
|
+
/** Send a dismiss message to the server so dashboard can close the stale dialog */
|
|
119
|
+
function sendDismiss(requestId: string): void {
|
|
120
|
+
send({
|
|
121
|
+
type: "extension_ui_dismiss",
|
|
122
|
+
sessionId: getSessionId(),
|
|
123
|
+
requestId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Race TUI promise against dashboard promise with proper cancellation.
|
|
129
|
+
* When TUI wins: clean up pending Map entry + send dismiss to server.
|
|
130
|
+
* When dashboard wins: abort TUI dialog via AbortController.
|
|
131
|
+
*/
|
|
132
|
+
function raceWithCancellation<T>(requestId: string, tuiPromise: Promise<T>, dashPromise: Promise<T>, ac: AbortController): Promise<T> {
|
|
133
|
+
// Wire up cross-cancellation before racing
|
|
134
|
+
tuiPromise.then(() => {
|
|
135
|
+
// TUI won — clean up dashboard side
|
|
136
|
+
pending.delete(requestId);
|
|
137
|
+
sendDismiss(requestId);
|
|
138
|
+
}).catch(() => {});
|
|
139
|
+
dashPromise.then(() => {
|
|
140
|
+
// Dashboard won — abort TUI dialog
|
|
141
|
+
ac.abort();
|
|
142
|
+
}).catch(() => {});
|
|
143
|
+
return Promise.race([tuiPromise, dashPromise]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const wrappedUi = {
|
|
147
|
+
confirm: (title: string, message: string, opts?: any): Promise<boolean> => {
|
|
148
|
+
const params = { title, message };
|
|
149
|
+
const requestId = sendRequest("confirm", params);
|
|
150
|
+
const dashPromise = createDashboardPromise<boolean>(requestId, "confirm", params);
|
|
151
|
+
|
|
152
|
+
if (hasUI && !inProxy) {
|
|
153
|
+
const ac = new AbortController();
|
|
154
|
+
inProxy = true;
|
|
155
|
+
const originalPromise = originalConfirm(title, message, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
|
|
156
|
+
return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
|
|
157
|
+
}
|
|
158
|
+
return dashPromise;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
select: (title: string, selectOptions: string[], opts?: any): Promise<string | undefined> => {
|
|
162
|
+
const message = opts?.message as string | undefined;
|
|
163
|
+
const params = { title, options: selectOptions, ...(message ? { message } : {}) };
|
|
164
|
+
const requestId = sendRequest("select", params);
|
|
165
|
+
const dashPromise = createDashboardPromise<string | undefined>(requestId, "select", params);
|
|
166
|
+
|
|
167
|
+
if (hasUI && !inProxy) {
|
|
168
|
+
const ac = new AbortController();
|
|
169
|
+
inProxy = true;
|
|
170
|
+
const tuiTitle = message ? `${title}\n\n${message}` : title;
|
|
171
|
+
const originalPromise = originalSelect(tuiTitle, selectOptions, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
|
|
172
|
+
return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
|
|
173
|
+
}
|
|
174
|
+
return dashPromise;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
input: (title: string, placeholder?: string, opts?: any): Promise<string | undefined> => {
|
|
178
|
+
const message = opts?.message as string | undefined;
|
|
179
|
+
const params = { title, placeholder, ...(message ? { message } : {}) };
|
|
180
|
+
const requestId = sendRequest("input", params);
|
|
181
|
+
const dashPromise = createDashboardPromise<string | undefined>(requestId, "input", params);
|
|
182
|
+
|
|
183
|
+
if (hasUI && !inProxy) {
|
|
184
|
+
const ac = new AbortController();
|
|
185
|
+
inProxy = true;
|
|
186
|
+
const tuiTitle = message ? `${title}\n\n${message}` : title;
|
|
187
|
+
const originalPromise = originalInput(tuiTitle, placeholder, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
|
|
188
|
+
return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
|
|
189
|
+
}
|
|
190
|
+
return dashPromise;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
editor: (title: string, prefill?: string, opts?: any): Promise<string | undefined> => {
|
|
194
|
+
const params = { title, prefill };
|
|
195
|
+
const requestId = sendRequest("editor", params);
|
|
196
|
+
const dashPromise = createDashboardPromise<string | undefined>(requestId, "editor", params);
|
|
197
|
+
|
|
198
|
+
if (hasUI && !inProxy && originalEditor) {
|
|
199
|
+
const ac = new AbortController();
|
|
200
|
+
inProxy = true;
|
|
201
|
+
const originalPromise = originalEditor(title, prefill, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
|
|
202
|
+
return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
|
|
203
|
+
}
|
|
204
|
+
return dashPromise;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
multiselect: (title: string, selectOptions: string[], opts?: any): Promise<string[]> => {
|
|
208
|
+
const message = opts?.message as string | undefined;
|
|
209
|
+
const params = { title, options: selectOptions, ...(message ? { message } : {}) };
|
|
210
|
+
const requestId = sendRequest("multiselect", params);
|
|
211
|
+
const dashPromise = createDashboardPromise<string[]>(requestId, "multiselect", params);
|
|
212
|
+
|
|
213
|
+
if (hasUI && !inProxy) {
|
|
214
|
+
const ac = new AbortController();
|
|
215
|
+
inProxy = true;
|
|
216
|
+
const numbered = selectOptions.map((o, i) => `${i + 1}. ${o}`).join("\n");
|
|
217
|
+
const tuiBase = message ? `${title}\n\n${message}` : title;
|
|
218
|
+
const tuiPromise = originalInput(`${tuiBase}\n${numbered}`, "e.g. 1,3", { signal: ac.signal }).then((raw) => {
|
|
219
|
+
if (!raw) return [] as string[];
|
|
220
|
+
return raw
|
|
221
|
+
.split(",")
|
|
222
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
223
|
+
.filter((n) => !isNaN(n) && n >= 1 && n <= selectOptions.length)
|
|
224
|
+
.map((n) => selectOptions[n - 1]);
|
|
225
|
+
}).finally(() => { inProxy = false; });
|
|
226
|
+
return raceWithCancellation(requestId, tuiPromise, dashPromise, ac);
|
|
227
|
+
}
|
|
228
|
+
return dashPromise;
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
notify: (message: string, type?: string): void => {
|
|
232
|
+
originalNotify(message, type);
|
|
233
|
+
sendRequest("notify", { message, level: type });
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function handleResponse(response: ExtensionUiResponseMessage): void {
|
|
238
|
+
const entry = pending.get(response.requestId);
|
|
239
|
+
if (!entry) return;
|
|
240
|
+
|
|
241
|
+
pending.delete(response.requestId);
|
|
242
|
+
entry.resolve(extractResult(entry.method, response));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cancel all pending UI requests. Resolves each pending promise with a
|
|
247
|
+
* "cancelled" result so the TUI dialogs are dismissed.
|
|
248
|
+
*
|
|
249
|
+
* Used when an external channel (e.g. architect_prompt_response) answers a
|
|
250
|
+
* question that was also forwarded through the ui-proxy. Without this, the
|
|
251
|
+
* TUI dialog would stay open forever because the proxy’s dashPromise never
|
|
252
|
+
* resolves.
|
|
253
|
+
*/
|
|
254
|
+
function cancelAllPending(): void {
|
|
255
|
+
for (const [requestId, entry] of pending) {
|
|
256
|
+
const cancelled: ExtensionUiResponseMessage = {
|
|
257
|
+
type: "extension_ui_response",
|
|
258
|
+
sessionId: getSessionId(),
|
|
259
|
+
requestId,
|
|
260
|
+
cancelled: true,
|
|
261
|
+
};
|
|
262
|
+
entry.resolve(extractResult(entry.method, cancelled));
|
|
263
|
+
sendDismiss(requestId);
|
|
264
|
+
}
|
|
265
|
+
pending.clear();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { wrappedUi, handleResponse, resendPending, cancelAllPending };
|
|
269
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blackbelt-technology/pi-dashboard-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Dashboard server for monitoring and interacting with pi agent sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/cli.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pi-dashboard": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"scripts/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"postinstall": "node scripts/fix-pty-permissions.cjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@blackbelt-technology/pi-dashboard-shared": "*",
|
|
19
|
+
"@fastify/cookie": "^11.0.2",
|
|
20
|
+
"@fastify/cors": "^11.0.0",
|
|
21
|
+
"@fastify/http-proxy": "^11.4.3",
|
|
22
|
+
"@fastify/reply-from": "^12.6.1",
|
|
23
|
+
"@fastify/static": "^8.0.0",
|
|
24
|
+
"@fastify/websocket": "^11.0.0",
|
|
25
|
+
"bonjour-service": "^1.3.0",
|
|
26
|
+
"diff": "^8.0.3",
|
|
27
|
+
"fastify": "^5.0.0",
|
|
28
|
+
"jsonwebtoken": "^9.0.3",
|
|
29
|
+
"node-pty": "^1.1.0",
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/diff": "^7.0.0",
|
|
34
|
+
"@types/jsonwebtoken": "^9.0.9",
|
|
35
|
+
"@types/ws": "^8.18.1"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateWsUpgrade, escapeHtml, isBypassed } from "../auth-plugin.js";
|
|
3
|
+
import { isBypassedHost } from "../localhost-guard.js";
|
|
4
|
+
import { signToken, COOKIE_NAME } from "../auth.js";
|
|
5
|
+
|
|
6
|
+
const SECRET = "test-secret-for-ws-auth-testing";
|
|
7
|
+
|
|
8
|
+
describe("validateWsUpgrade", () => {
|
|
9
|
+
it("should allow localhost without cookie", () => {
|
|
10
|
+
expect(validateWsUpgrade(undefined, "127.0.0.1", SECRET)).toBe(true);
|
|
11
|
+
expect(validateWsUpgrade(undefined, "::1", SECRET)).toBe(true);
|
|
12
|
+
expect(validateWsUpgrade(undefined, "::ffff:127.0.0.1", SECRET)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should reject external request without cookie", () => {
|
|
16
|
+
expect(validateWsUpgrade(undefined, "1.2.3.4", SECRET)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should reject external request with invalid cookie", () => {
|
|
20
|
+
expect(validateWsUpgrade(`${COOKIE_NAME}=invalidtoken`, "1.2.3.4", SECRET)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should allow external request with valid cookie", () => {
|
|
24
|
+
const token = signToken({ sub: "user@example.com", name: "User", username: "user", provider: "github" }, SECRET);
|
|
25
|
+
expect(validateWsUpgrade(`${COOKIE_NAME}=${token}`, "1.2.3.4", SECRET)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should reject external request with wrong secret", () => {
|
|
29
|
+
const token = signToken({ sub: "user@example.com", name: "User", username: "user", provider: "github" }, "other-secret");
|
|
30
|
+
expect(validateWsUpgrade(`${COOKIE_NAME}=${token}`, "1.2.3.4", SECRET)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("isBypassed", () => {
|
|
35
|
+
it("should return false for empty bypassUrls list", () => {
|
|
36
|
+
expect(isBypassed("/api/sessions", [])).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should return true when URL starts with a bypass prefix", () => {
|
|
40
|
+
expect(isBypassed("/webhooks/github", ["/webhooks/"])).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return false when URL does not start with any bypass prefix", () => {
|
|
44
|
+
expect(isBypassed("/api/sessions", ["/webhooks/"])).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should match multiple prefixes", () => {
|
|
48
|
+
expect(isBypassed("/metrics", ["/webhooks/", "/metrics"])).toBe(true);
|
|
49
|
+
expect(isBypassed("/healthz/ready", ["/healthz", "/metrics"])).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should match because startsWith is a prefix check (not word-boundary)", () => {
|
|
53
|
+
// /api/public IS a prefix of /api/publications — this is expected, documented behaviour
|
|
54
|
+
expect(isBypassed("/api/publications", ["/api/public"])).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not match when prefix is only a substring in the middle", () => {
|
|
58
|
+
expect(isBypassed("/v1/webhooks/data", ["/webhooks/"])).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should return false when no prefix matches", () => {
|
|
62
|
+
expect(isBypassed("/secure/data", ["/webhooks/", "/metrics"])).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isBypassedHost", () => {
|
|
67
|
+
it("should return false for empty bypass list", () => {
|
|
68
|
+
expect(isBypassedHost("10.0.0.5", [])).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should match exact IP", () => {
|
|
72
|
+
expect(isBypassedHost("10.0.0.5", ["10.0.0.5"])).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should match exact hostname", () => {
|
|
76
|
+
expect(isBypassedHost("build-server.local", ["build-server.local"])).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should not match different IP", () => {
|
|
80
|
+
expect(isBypassedHost("10.0.0.6", ["10.0.0.5"])).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should match CIDR notation", () => {
|
|
84
|
+
expect(isBypassedHost("192.168.1.50", ["192.168.1.0/24"])).toBe(true);
|
|
85
|
+
expect(isBypassedHost("192.168.2.50", ["192.168.1.0/24"])).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should match wildcard subnet", () => {
|
|
89
|
+
expect(isBypassedHost("10.0.0.99", ["10.0.0.*"])).toBe(true);
|
|
90
|
+
expect(isBypassedHost("10.0.1.99", ["10.0.0.*"])).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should match multiple entries", () => {
|
|
94
|
+
expect(isBypassedHost("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("escapeHtml", () => {
|
|
99
|
+
it("should escape all HTML special characters", () => {
|
|
100
|
+
expect(escapeHtml('&<>"\'')).toBe("&<>"'");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should escape script tags to prevent XSS", () => {
|
|
104
|
+
expect(escapeHtml('<script>alert(1)</script>')).toBe("<script>alert(1)</script>");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should escape crafted email addresses", () => {
|
|
108
|
+
expect(escapeHtml('<img onerror="alert(1)" src=x>@evil.com')).toBe(
|
|
109
|
+
'<img onerror="alert(1)" src=x>@evil.com',
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should pass through safe strings unchanged", () => {
|
|
114
|
+
expect(escapeHtml("user@example.com")).toBe("user@example.com");
|
|
115
|
+
expect(escapeHtml("hello world")).toBe("hello world");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
signToken,
|
|
4
|
+
verifyToken,
|
|
5
|
+
parseAuthCookie,
|
|
6
|
+
isUserAllowed,
|
|
7
|
+
ensureAuthSecret,
|
|
8
|
+
buildAuthorizeUrl,
|
|
9
|
+
buildProviderRegistry,
|
|
10
|
+
COOKIE_NAME,
|
|
11
|
+
type AuthUser,
|
|
12
|
+
type ResolvedProvider,
|
|
13
|
+
} from "../auth.js";
|
|
14
|
+
import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
|
|
19
|
+
// ─── JWT Token Tests ────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe("signToken / verifyToken", () => {
|
|
22
|
+
const secret = "test-secret-32-chars-long-abcdef";
|
|
23
|
+
const user: AuthUser = { sub: "user@example.com", name: "Test User", username: "testuser", provider: "github" };
|
|
24
|
+
|
|
25
|
+
it("should sign and verify a token", () => {
|
|
26
|
+
const token = signToken(user, secret);
|
|
27
|
+
const payload = verifyToken(token, secret);
|
|
28
|
+
expect(payload).not.toBeNull();
|
|
29
|
+
expect(payload!.sub).toBe("user@example.com");
|
|
30
|
+
expect(payload!.name).toBe("Test User");
|
|
31
|
+
expect(payload!.provider).toBe("github");
|
|
32
|
+
expect(payload!.exp).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return null for tampered token", () => {
|
|
36
|
+
const token = signToken(user, secret);
|
|
37
|
+
const tampered = token.slice(0, -5) + "XXXXX";
|
|
38
|
+
expect(verifyToken(tampered, secret)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should return null for wrong secret", () => {
|
|
42
|
+
const token = signToken(user, secret);
|
|
43
|
+
expect(verifyToken(token, "wrong-secret")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should return null for garbage string", () => {
|
|
47
|
+
expect(verifyToken("not.a.jwt", secret)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── Cookie Parsing Tests ───────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe("parseAuthCookie", () => {
|
|
54
|
+
it("should parse cookie from header", () => {
|
|
55
|
+
const header = `other=abc; ${COOKIE_NAME}=mytoken123; another=def`;
|
|
56
|
+
expect(parseAuthCookie(header)).toBe("mytoken123");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should return null when cookie not present", () => {
|
|
60
|
+
expect(parseAuthCookie("other=abc")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should return null for undefined header", () => {
|
|
64
|
+
expect(parseAuthCookie(undefined)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should parse when cookie is first", () => {
|
|
68
|
+
expect(parseAuthCookie(`${COOKIE_NAME}=token1; other=x`)).toBe("token1");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should parse when cookie is only value", () => {
|
|
72
|
+
expect(parseAuthCookie(`${COOKIE_NAME}=singletoken`)).toBe("singletoken");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── Email Allowlist Tests ──────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("isUserAllowed", () => {
|
|
79
|
+
it("should allow any user when allowedUsers is undefined", () => {
|
|
80
|
+
expect(isUserAllowed("anyone@example.com", "anyone")).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should allow any user when allowedUsers is empty", () => {
|
|
84
|
+
expect(isUserAllowed("anyone@example.com", "anyone", [])).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should allow exact email match", () => {
|
|
88
|
+
expect(isUserAllowed("user@example.com", "user", ["user@example.com"])).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should allow exact username match", () => {
|
|
92
|
+
expect(isUserAllowed("other@example.com", "octocat", ["octocat"])).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should reject non-matching email and username", () => {
|
|
96
|
+
expect(isUserAllowed("other@example.com", "other", ["user@example.com"])).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should support domain wildcard", () => {
|
|
100
|
+
expect(isUserAllowed("anyone@company.com", "anyone", ["*@company.com"])).toBe(true);
|
|
101
|
+
expect(isUserAllowed("anyone@other.com", "anyone", ["*@company.com"])).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should be case-insensitive for email", () => {
|
|
105
|
+
expect(isUserAllowed("User@Example.COM", "user", ["user@example.com"])).toBe(true);
|
|
106
|
+
expect(isUserAllowed("test@Company.Com", "test", ["*@company.com"])).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should be case-insensitive for username", () => {
|
|
110
|
+
expect(isUserAllowed("x@y.com", "OctoCat", ["octocat"])).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Auth Secret Management Tests ───────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("ensureAuthSecret", () => {
|
|
117
|
+
let testDir: string;
|
|
118
|
+
let configFile: string;
|
|
119
|
+
let origHome: string;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
testDir = path.join(os.tmpdir(), `test-auth-secret-${Date.now()}`);
|
|
123
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
124
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
125
|
+
origHome = process.env.HOME!;
|
|
126
|
+
process.env.HOME = testDir;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
process.env.HOME = origHome;
|
|
131
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should return existing secret when present", () => {
|
|
135
|
+
const config: AuthConfig = {
|
|
136
|
+
secret: "existing-secret",
|
|
137
|
+
providers: { github: { clientId: "id", clientSecret: "sec" } },
|
|
138
|
+
};
|
|
139
|
+
expect(ensureAuthSecret(config)).toBe("existing-secret");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should generate secret when missing and update config object", () => {
|
|
143
|
+
// Note: persistence to file depends on CONFIG_FILE which is resolved at module load.
|
|
144
|
+
// We test the in-memory behavior here.
|
|
145
|
+
const config: AuthConfig = {
|
|
146
|
+
secret: "",
|
|
147
|
+
providers: { github: { clientId: "id", clientSecret: "sec" } },
|
|
148
|
+
};
|
|
149
|
+
const secret = ensureAuthSecret(config);
|
|
150
|
+
|
|
151
|
+
expect(secret).toHaveLength(32);
|
|
152
|
+
expect(config.secret).toBe(secret);
|
|
153
|
+
// Secret should be hex
|
|
154
|
+
expect(/^[0-9a-f]{32}$/.test(secret)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should generate different secrets each time", () => {
|
|
158
|
+
const config1: AuthConfig = { secret: "", providers: {} };
|
|
159
|
+
const config2: AuthConfig = { secret: "", providers: {} };
|
|
160
|
+
const s1 = ensureAuthSecret(config1);
|
|
161
|
+
const s2 = ensureAuthSecret(config2);
|
|
162
|
+
expect(s1).not.toBe(s2);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ─── Provider Registry Tests ────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("buildProviderRegistry", () => {
|
|
169
|
+
// We test buildProviderRegistry indirectly through the resolved provider for GitHub
|
|
170
|
+
// (since Google/Keycloak/OIDC require OIDC discovery which needs network)
|
|
171
|
+
|
|
172
|
+
it("should resolve GitHub provider with hardcoded endpoints", async () => {
|
|
173
|
+
const registry = await buildProviderRegistry({
|
|
174
|
+
github: { clientId: "gh-id", clientSecret: "gh-secret" },
|
|
175
|
+
});
|
|
176
|
+
expect(registry.size).toBe(1);
|
|
177
|
+
const gh = registry.get("github")!;
|
|
178
|
+
expect(gh.name).toBe("GitHub");
|
|
179
|
+
expect(gh.authorizeUrl).toBe("https://github.com/login/oauth/authorize");
|
|
180
|
+
expect(gh.tokenUrl).toBe("https://github.com/login/oauth/access_token");
|
|
181
|
+
expect(gh.userInfoUrl).toBe("https://api.github.com/user");
|
|
182
|
+
expect(gh.scopes).toBe("user:email");
|
|
183
|
+
expect(gh.clientId).toBe("gh-id");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should return empty registry for empty providers", async () => {
|
|
187
|
+
const registry = await buildProviderRegistry({});
|
|
188
|
+
expect(registry.size).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should skip providers that fail to resolve (e.g. OIDC without issuerUrl)", async () => {
|
|
192
|
+
const registry = await buildProviderRegistry({
|
|
193
|
+
keycloak: { clientId: "kc", clientSecret: "ks" }, // missing issuerUrl
|
|
194
|
+
github: { clientId: "gh", clientSecret: "gs" },
|
|
195
|
+
});
|
|
196
|
+
// keycloak should be skipped (no issuerUrl), github should resolve
|
|
197
|
+
expect(registry.size).toBe(1);
|
|
198
|
+
expect(registry.has("github")).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ─── Authorize URL Builder Tests ────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
describe("buildAuthorizeUrl", () => {
|
|
205
|
+
it("should build correct authorize URL", () => {
|
|
206
|
+
const provider: ResolvedProvider = {
|
|
207
|
+
key: "github",
|
|
208
|
+
name: "GitHub",
|
|
209
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
210
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
211
|
+
userInfoUrl: "https://api.github.com/user",
|
|
212
|
+
scopes: "user:email",
|
|
213
|
+
clientId: "my-client-id",
|
|
214
|
+
clientSecret: "secret",
|
|
215
|
+
};
|
|
216
|
+
const url = buildAuthorizeUrl(provider, "http://localhost:8000/auth/callback/github", "state123");
|
|
217
|
+
expect(url).toContain("https://github.com/login/oauth/authorize?");
|
|
218
|
+
expect(url).toContain("client_id=my-client-id");
|
|
219
|
+
expect(url).toContain("redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fcallback%2Fgithub");
|
|
220
|
+
expect(url).toContain("scope=user%3Aemail");
|
|
221
|
+
expect(url).toContain("state=state123");
|
|
222
|
+
expect(url).toContain("response_type=code");
|
|
223
|
+
});
|
|
224
|
+
});
|