@grackle-ai/server 0.75.0 → 0.75.2
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/dist/__mocks__/adapter-manager.d.ts +9 -0
- package/dist/__mocks__/adapter-manager.d.ts.map +1 -0
- package/dist/__mocks__/adapter-manager.js +10 -0
- package/dist/__mocks__/adapter-manager.js.map +1 -0
- package/dist/__mocks__/auto-reconnect.d.ts +5 -0
- package/dist/__mocks__/auto-reconnect.d.ts.map +1 -0
- package/dist/__mocks__/auto-reconnect.js +6 -0
- package/dist/__mocks__/auto-reconnect.js.map +1 -0
- package/dist/__mocks__/event-bus.d.ts +4 -0
- package/dist/__mocks__/event-bus.d.ts.map +1 -0
- package/dist/__mocks__/event-bus.js +5 -0
- package/dist/__mocks__/event-bus.js.map +1 -0
- package/dist/__mocks__/event-processor.d.ts +4 -0
- package/dist/__mocks__/event-processor.d.ts.map +1 -0
- package/dist/__mocks__/event-processor.js +5 -0
- package/dist/__mocks__/event-processor.js.map +1 -0
- package/dist/__mocks__/github-import.d.ts +7 -0
- package/dist/__mocks__/github-import.d.ts.map +1 -0
- package/dist/__mocks__/github-import.js +8 -0
- package/dist/__mocks__/github-import.js.map +1 -0
- package/dist/__mocks__/knowledge-init.d.ts +4 -0
- package/dist/__mocks__/knowledge-init.d.ts.map +1 -0
- package/dist/__mocks__/knowledge-init.js +5 -0
- package/dist/__mocks__/knowledge-init.js.map +1 -0
- package/dist/__mocks__/lifecycle.d.ts +4 -0
- package/dist/__mocks__/lifecycle.d.ts.map +1 -0
- package/dist/__mocks__/lifecycle.js +5 -0
- package/dist/__mocks__/lifecycle.js.map +1 -0
- package/dist/__mocks__/log-writer.d.ts +7 -0
- package/dist/__mocks__/log-writer.d.ts.map +1 -0
- package/dist/__mocks__/log-writer.js +8 -0
- package/dist/__mocks__/log-writer.js.map +1 -0
- package/dist/__mocks__/logger.d.ts +7 -0
- package/dist/__mocks__/logger.d.ts.map +1 -0
- package/dist/__mocks__/logger.js +3 -0
- package/dist/__mocks__/logger.js.map +1 -0
- package/dist/__mocks__/pipe-delivery.d.ts +6 -0
- package/dist/__mocks__/pipe-delivery.d.ts.map +1 -0
- package/dist/__mocks__/pipe-delivery.js +7 -0
- package/dist/__mocks__/pipe-delivery.js.map +1 -0
- package/dist/__mocks__/processor-registry.d.ts +6 -0
- package/dist/__mocks__/processor-registry.d.ts.map +1 -0
- package/dist/__mocks__/processor-registry.js +7 -0
- package/dist/__mocks__/processor-registry.js.map +1 -0
- package/dist/__mocks__/reanimate-agent.d.ts +2 -0
- package/dist/__mocks__/reanimate-agent.d.ts.map +1 -0
- package/dist/__mocks__/reanimate-agent.js +3 -0
- package/dist/__mocks__/reanimate-agent.js.map +1 -0
- package/dist/__mocks__/session-recovery.d.ts +3 -0
- package/dist/__mocks__/session-recovery.d.ts.map +1 -0
- package/dist/__mocks__/session-recovery.js +4 -0
- package/dist/__mocks__/session-recovery.js.map +1 -0
- package/dist/__mocks__/stream-hub.d.ts +9 -0
- package/dist/__mocks__/stream-hub.d.ts.map +1 -0
- package/dist/__mocks__/stream-hub.js +10 -0
- package/dist/__mocks__/stream-hub.js.map +1 -0
- package/dist/__mocks__/stream-registry.d.ts +16 -0
- package/dist/__mocks__/stream-registry.d.ts.map +1 -0
- package/dist/__mocks__/stream-registry.js +17 -0
- package/dist/__mocks__/stream-registry.js.map +1 -0
- package/dist/__mocks__/token-push.d.ts +5 -0
- package/dist/__mocks__/token-push.d.ts.map +1 -0
- package/dist/__mocks__/token-push.js +6 -0
- package/dist/__mocks__/token-push.js.map +1 -0
- package/dist/__mocks__/utils/exec.d.ts +3 -0
- package/dist/__mocks__/utils/exec.d.ts.map +1 -0
- package/dist/__mocks__/utils/exec.js +3 -0
- package/dist/__mocks__/utils/exec.js.map +1 -0
- package/dist/__mocks__/utils/format-gh-error.d.ts +3 -0
- package/dist/__mocks__/utils/format-gh-error.d.ts.map +1 -0
- package/dist/__mocks__/utils/format-gh-error.js +3 -0
- package/dist/__mocks__/utils/format-gh-error.js.map +1 -0
- package/dist/__mocks__/utils/network.d.ts +3 -0
- package/dist/__mocks__/utils/network.d.ts.map +1 -0
- package/dist/__mocks__/utils/network.js +3 -0
- package/dist/__mocks__/utils/network.js.map +1 -0
- package/dist/index.js +9 -525
- package/dist/index.js.map +1 -1
- package/dist/test-utils/integration-setup.d.ts +11 -0
- package/dist/test-utils/integration-setup.d.ts.map +1 -0
- package/dist/test-utils/integration-setup.js +32 -0
- package/dist/test-utils/integration-setup.js.map +1 -0
- package/package.json +15 -14
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { connectNodeAdapter } from "@connectrpc/connect-node";
|
|
2
2
|
import { ConnectError, Code } from "@connectrpc/connect";
|
|
3
3
|
import http2 from "node:http2";
|
|
4
|
-
import http from "node:http";
|
|
5
4
|
import { registerGrackleRoutes } from "./grpc-service.js";
|
|
6
5
|
import { registerAdapter, startHeartbeat } from "./adapter-manager.js";
|
|
7
6
|
import { envRegistry, sessionStore, workspaceStore, taskStore, openDatabase, initDatabase, sqlite, seedDatabase, credentialProviders, grackleHome } from "@grackle-ai/database";
|
|
@@ -23,534 +22,14 @@ import * as tokenPush from "./token-push.js";
|
|
|
23
22
|
import { attemptReconnects, resetReconnectState } from "./auto-reconnect.js";
|
|
24
23
|
import { createMcpServer } from "@grackle-ai/mcp";
|
|
25
24
|
import { isKnowledgeEnabled, initKnowledge } from "./knowledge-init.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
25
|
+
import { loadOrCreateApiKey, verifyApiKey, setAuthLogger, generatePairingCode, startSessionCleanup, stopSessionCleanup, startPairingCleanup, stopPairingCleanup, startOAuthCleanup, stopOAuthCleanup, validateSessionCookie, } from "@grackle-ai/auth";
|
|
26
|
+
import { createWebServer, isWildcardAddress } from "@grackle-ai/web-server";
|
|
28
27
|
import { createRequire } from "node:module";
|
|
29
|
-
import { loadOrCreateApiKey, verifyApiKey, setSecurityHeaders, setAuthLogger, createSession, validateSessionCookie, startSessionCleanup, stopSessionCleanup, generatePairingCode, redeemPairingCode, startPairingCleanup, stopPairingCleanup, registerClient, getClient, createAuthorizationCode, consumeAuthorizationCode, createRefreshToken, consumeRefreshToken, startOAuthCleanup, stopOAuthCleanup, createOAuthAccessToken, OAUTH_ACCESS_TOKEN_TTL_MS, } from "@grackle-ai/auth";
|
|
30
28
|
import { logger } from "./logger.js";
|
|
31
29
|
import { exec } from "./utils/exec.js";
|
|
32
30
|
import { detectLanIp } from "./utils/network.js";
|
|
33
|
-
|
|
34
|
-
".html": "text/html",
|
|
35
|
-
".js": "application/javascript",
|
|
36
|
-
".css": "text/css",
|
|
37
|
-
".json": "application/json",
|
|
38
|
-
".svg": "image/svg+xml",
|
|
39
|
-
".png": "image/png",
|
|
40
|
-
".ico": "image/x-icon",
|
|
41
|
-
};
|
|
42
|
-
/** Resolve the web UI dist directory once at module load time. */
|
|
31
|
+
/** Require function for loading optional native modules (qrcode). */
|
|
43
32
|
const esmRequire = createRequire(import.meta.url);
|
|
44
|
-
const WEB_DIST_DIR = resolve(process.env.GRACKLE_WEB_DIR
|
|
45
|
-
|| join(dirname(esmRequire.resolve("@grackle-ai/web/package.json")), "dist"));
|
|
46
|
-
/** Minimal HTML page shown when the user needs to enter a pairing code. */
|
|
47
|
-
function renderPairingPage(error) {
|
|
48
|
-
const errorHtml = error ? `<p style="color:#e74c3c;margin-bottom:1rem">${escapeHtml(error)}</p>` : "";
|
|
49
|
-
return `<!DOCTYPE html>
|
|
50
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
51
|
-
<title>Grackle — Pair Device</title>
|
|
52
|
-
<style>
|
|
53
|
-
*{box-sizing:border-box;margin:0;padding:0}
|
|
54
|
-
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#0f172a;color:#e2e8f0}
|
|
55
|
-
.card{background:#1e293b;border-radius:12px;padding:2.5rem;max-width:400px;width:100%;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.3)}
|
|
56
|
-
h1{font-size:1.5rem;margin-bottom:.5rem}
|
|
57
|
-
p{color:#94a3b8;margin-bottom:1.5rem;font-size:.95rem}
|
|
58
|
-
input{width:100%;padding:.75rem 1rem;font-size:1.25rem;letter-spacing:.3em;text-align:center;border:2px solid #334155;border-radius:8px;background:#0f172a;color:#e2e8f0;text-transform:uppercase;font-family:monospace}
|
|
59
|
-
input:focus{outline:none;border-color:#3b82f6}
|
|
60
|
-
button{margin-top:1rem;width:100%;padding:.75rem;font-size:1rem;border:none;border-radius:8px;background:#3b82f6;color:#fff;cursor:pointer;font-weight:600}
|
|
61
|
-
button:hover{background:#2563eb}
|
|
62
|
-
</style></head><body>
|
|
63
|
-
<div class="card">
|
|
64
|
-
<h1>Grackle</h1>
|
|
65
|
-
<p>Enter the pairing code shown in your terminal.</p>
|
|
66
|
-
${errorHtml}
|
|
67
|
-
<form method="GET" action="/pair">
|
|
68
|
-
<input name="code" type="text" maxlength="6" pattern="[A-Za-z0-9]{6}" autocomplete="off" autofocus placeholder="ABC123" required>
|
|
69
|
-
<button type="submit">Pair</button>
|
|
70
|
-
</form>
|
|
71
|
-
</div></body></html>`;
|
|
72
|
-
}
|
|
73
|
-
/** Shared card styles used by both pairing and authorize pages. */
|
|
74
|
-
const CARD_STYLES = `*{box-sizing:border-box;margin:0;padding:0}
|
|
75
|
-
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#0f172a;color:#e2e8f0}
|
|
76
|
-
.card{background:#1e293b;border-radius:12px;padding:2.5rem;max-width:400px;width:100%;text-align:center;box-shadow:0 4px 24px rgba(0,0,0,.3)}
|
|
77
|
-
h1{font-size:1.5rem;margin-bottom:.5rem}
|
|
78
|
-
p{color:#94a3b8;margin-bottom:1.5rem;font-size:.95rem}
|
|
79
|
-
input{width:100%;padding:.75rem 1rem;font-size:1.25rem;letter-spacing:.3em;text-align:center;border:2px solid #334155;border-radius:8px;background:#0f172a;color:#e2e8f0;text-transform:uppercase;font-family:monospace;margin-bottom:.5rem}
|
|
80
|
-
input:focus{outline:none;border-color:#3b82f6}
|
|
81
|
-
button{margin-top:1rem;width:100%;padding:.75rem;font-size:1rem;border:none;border-radius:8px;background:#3b82f6;color:#fff;cursor:pointer;font-weight:600}
|
|
82
|
-
button:hover{background:#2563eb}
|
|
83
|
-
.btn-deny{background:#475569;margin-top:.5rem}
|
|
84
|
-
.btn-deny:hover{background:#334155}
|
|
85
|
-
.client-name{color:#3b82f6;font-weight:600}`;
|
|
86
|
-
/**
|
|
87
|
-
* Render the OAuth authorize page.
|
|
88
|
-
*
|
|
89
|
-
* If the user has a valid session, shows a simple "Authorize" / "Deny" form.
|
|
90
|
-
* If not paired, adds a pairing code input so the user can pair and authorize in one step.
|
|
91
|
-
*/
|
|
92
|
-
function renderAuthorizePage(clientName, oauthParams, hasPairedSession, error) {
|
|
93
|
-
const errorHtml = error ? `<p style="color:#e74c3c;margin-bottom:1rem">${escapeHtml(error)}</p>` : "";
|
|
94
|
-
const pairingField = hasPairedSession
|
|
95
|
-
? ""
|
|
96
|
-
: `<p>Enter the pairing code shown in your terminal to pair and authorize.</p>
|
|
97
|
-
<input name="pairing_code" type="text" maxlength="6" pattern="[A-Za-z0-9]{6}" autocomplete="off" autofocus placeholder="ABC123" required>`;
|
|
98
|
-
const buttonLabel = hasPairedSession ? "Authorize" : "Pair & Authorize";
|
|
99
|
-
return `<!DOCTYPE html>
|
|
100
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
101
|
-
<title>Grackle — Authorize MCP Client</title>
|
|
102
|
-
<style>${CARD_STYLES}</style></head><body>
|
|
103
|
-
<div class="card">
|
|
104
|
-
<h1>Authorize MCP Client</h1>
|
|
105
|
-
<p><span class="client-name">${escapeHtml(clientName)}</span> wants to connect to Grackle.</p>
|
|
106
|
-
${errorHtml}
|
|
107
|
-
<form method="POST" action="/authorize">
|
|
108
|
-
${pairingField}
|
|
109
|
-
<input type="hidden" name="oauth_params" value="${escapeHtml(oauthParams)}">
|
|
110
|
-
<button type="submit" name="action" value="approve">${buttonLabel}</button>
|
|
111
|
-
<button type="submit" name="action" value="deny" class="btn-deny">Deny</button>
|
|
112
|
-
</form>
|
|
113
|
-
</div></body></html>`;
|
|
114
|
-
}
|
|
115
|
-
/** Escape HTML special characters for safe embedding in attributes and text content. */
|
|
116
|
-
function escapeHtml(str) {
|
|
117
|
-
return str
|
|
118
|
-
.replace(/&/g, "&")
|
|
119
|
-
.replace(/</g, "<")
|
|
120
|
-
.replace(/>/g, ">")
|
|
121
|
-
.replace(/"/g, """)
|
|
122
|
-
.replace(/'/g, "'");
|
|
123
|
-
}
|
|
124
|
-
/** Maximum size for form/JSON request bodies: 16 KB. */
|
|
125
|
-
const MAX_BODY_SIZE = 16_384;
|
|
126
|
-
/**
|
|
127
|
-
* Read the raw body string from an HTTP request with size limit enforcement.
|
|
128
|
-
*
|
|
129
|
-
* @param req - The incoming HTTP request.
|
|
130
|
-
* @returns The raw body as a UTF-8 string.
|
|
131
|
-
*/
|
|
132
|
-
function readBody(req) {
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
const chunks = [];
|
|
135
|
-
let totalSize = 0;
|
|
136
|
-
req.on("data", (chunk) => {
|
|
137
|
-
totalSize += chunk.length;
|
|
138
|
-
if (totalSize > MAX_BODY_SIZE) {
|
|
139
|
-
req.destroy();
|
|
140
|
-
reject(new Error("Body too large"));
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
chunks.push(chunk);
|
|
144
|
-
});
|
|
145
|
-
req.on("end", () => {
|
|
146
|
-
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
147
|
-
});
|
|
148
|
-
req.on("error", reject);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Parse a URL-encoded form body from an HTTP request.
|
|
153
|
-
*
|
|
154
|
-
* @param req - The incoming HTTP request.
|
|
155
|
-
* @returns Parsed key-value pairs from the form body.
|
|
156
|
-
*/
|
|
157
|
-
async function parseFormBody(req) {
|
|
158
|
-
const raw = await readBody(req);
|
|
159
|
-
return new URLSearchParams(raw);
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Serve a static file from the web dist directory.
|
|
163
|
-
* Always writes a response (200, 403, 404, or 500).
|
|
164
|
-
*/
|
|
165
|
-
function serveStaticFile(req, res, rawPath) {
|
|
166
|
-
const isRoot = rawPath === "/" || rawPath === "";
|
|
167
|
-
let filePath = isRoot
|
|
168
|
-
? join(WEB_DIST_DIR, "index.html")
|
|
169
|
-
: resolve(WEB_DIST_DIR, normalize(`.${rawPath}`));
|
|
170
|
-
// Prevent path traversal — resolved path must stay within the dist directory
|
|
171
|
-
const rel = relative(WEB_DIST_DIR, filePath);
|
|
172
|
-
if (rel.startsWith("..") || resolve(WEB_DIST_DIR, rel) !== filePath) {
|
|
173
|
-
res.writeHead(403);
|
|
174
|
-
res.end("Forbidden");
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
if (!existsSync(filePath)) {
|
|
178
|
-
// SPA fallback
|
|
179
|
-
filePath = join(WEB_DIST_DIR, "index.html");
|
|
180
|
-
}
|
|
181
|
-
if (!existsSync(filePath)) {
|
|
182
|
-
res.writeHead(404);
|
|
183
|
-
res.end("Not found");
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
const ext = extname(filePath);
|
|
187
|
-
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
188
|
-
try {
|
|
189
|
-
const content = readFileSync(filePath);
|
|
190
|
-
res.writeHead(200, { "Content-Type": contentType });
|
|
191
|
-
res.end(content);
|
|
192
|
-
return true;
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
res.writeHead(500);
|
|
196
|
-
res.end("Server error");
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
/** Extract the remote IP from an incoming request. */
|
|
201
|
-
function getRemoteIp(req) {
|
|
202
|
-
return req.socket.remoteAddress || "unknown";
|
|
203
|
-
}
|
|
204
|
-
/** Static assets served without session authentication (favicons, manifest, logo). */
|
|
205
|
-
const PUBLIC_ASSETS = new Set([
|
|
206
|
-
"/favicon.ico",
|
|
207
|
-
"/favicon-16x16.png",
|
|
208
|
-
"/favicon-32x32.png",
|
|
209
|
-
"/apple-touch-icon.png",
|
|
210
|
-
"/manifest.json",
|
|
211
|
-
"/icon-192x192.png",
|
|
212
|
-
"/icon-512x512.png",
|
|
213
|
-
"/grackle-logo.png",
|
|
214
|
-
]);
|
|
215
|
-
/**
|
|
216
|
-
* Create the HTTP request handler for the web server.
|
|
217
|
-
*
|
|
218
|
-
* Serves OAuth authorization server endpoints (no auth),
|
|
219
|
-
* the pairing endpoint, and session-gated static files.
|
|
220
|
-
*/
|
|
221
|
-
function createWebHandler(apiKey, webPort, bindHost) {
|
|
222
|
-
/** Map wildcard bind hosts to a dialable host for OAuth URLs. */
|
|
223
|
-
const allowNetwork = isWildcardAddress(bindHost);
|
|
224
|
-
const dialableHost = allowNetwork ? "127.0.0.1" : bindHost;
|
|
225
|
-
const urlHost = dialableHost.includes(":") ? `[${dialableHost}]` : dialableHost;
|
|
226
|
-
const webBaseUrl = `http://${urlHost}:${webPort}`;
|
|
227
|
-
/** ConnectRPC handler for browser gRPC calls (Connect protocol over HTTP/1.1). */
|
|
228
|
-
const webConnectHandler = connectNodeAdapter({
|
|
229
|
-
routes: registerGrackleRoutes,
|
|
230
|
-
});
|
|
231
|
-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
232
|
-
return async (req, res) => {
|
|
233
|
-
setSecurityHeaders(res);
|
|
234
|
-
let rawPath;
|
|
235
|
-
let queryString = "";
|
|
236
|
-
try {
|
|
237
|
-
const urlParts = (req.url || "/").split("?");
|
|
238
|
-
rawPath = decodeURIComponent(urlParts[0]);
|
|
239
|
-
queryString = urlParts[1] || "";
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
res.writeHead(400);
|
|
243
|
-
res.end("Bad Request");
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
// --- OAuth Authorization Server Metadata (no auth) ---
|
|
247
|
-
if (rawPath === "/.well-known/oauth-authorization-server") {
|
|
248
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
249
|
-
res.end(JSON.stringify({
|
|
250
|
-
issuer: webBaseUrl,
|
|
251
|
-
authorization_endpoint: `${webBaseUrl}/authorize`,
|
|
252
|
-
token_endpoint: `${webBaseUrl}/token`,
|
|
253
|
-
registration_endpoint: `${webBaseUrl}/register`,
|
|
254
|
-
response_types_supported: ["code"],
|
|
255
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
256
|
-
code_challenge_methods_supported: ["S256"],
|
|
257
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
258
|
-
}));
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
// --- Dynamic Client Registration (no auth, JSON body) ---
|
|
262
|
-
if (rawPath === "/register" && req.method === "POST") {
|
|
263
|
-
try {
|
|
264
|
-
const raw = await readBody(req);
|
|
265
|
-
const parsed = JSON.parse(raw);
|
|
266
|
-
const redirectUris = parsed.redirect_uris;
|
|
267
|
-
const clientName = parsed.client_name;
|
|
268
|
-
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
269
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
270
|
-
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uris is required" }));
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
// Validate each redirect URI — only allow http(s) on loopback to prevent open redirects
|
|
274
|
-
for (const uri of redirectUris) {
|
|
275
|
-
try {
|
|
276
|
-
const parsed = new URL(uri);
|
|
277
|
-
const isLoopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1";
|
|
278
|
-
const isHttpOrHttps = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
279
|
-
if (!isLoopback || !isHttpOrHttps) {
|
|
280
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
281
|
-
res.end(JSON.stringify({ error: "invalid_client_metadata", error_description: "redirect_uris must use http(s) on loopback (127.0.0.1 or localhost)" }));
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
287
|
-
res.end(JSON.stringify({ error: "invalid_client_metadata", error_description: "Invalid redirect_uri" }));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
const client = registerClient(redirectUris, clientName);
|
|
292
|
-
if (!client) {
|
|
293
|
-
res.writeHead(503, { "Content-Type": "application/json" });
|
|
294
|
-
res.end(JSON.stringify({ error: "temporarily_unavailable", error_description: "Too many registered clients" }));
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
res.writeHead(201, { "Content-Type": "application/json" });
|
|
298
|
-
res.end(JSON.stringify({
|
|
299
|
-
client_id: client.clientId,
|
|
300
|
-
redirect_uris: client.redirectUris,
|
|
301
|
-
client_name: client.clientName,
|
|
302
|
-
}));
|
|
303
|
-
}
|
|
304
|
-
catch {
|
|
305
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
306
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
307
|
-
}
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
// --- OAuth Authorize (GET — render page, no auth required) ---
|
|
311
|
-
if (rawPath === "/authorize" && req.method === "GET") {
|
|
312
|
-
const params = new URLSearchParams(queryString);
|
|
313
|
-
const clientId = params.get("client_id") || "";
|
|
314
|
-
const responseType = params.get("response_type") || "";
|
|
315
|
-
const redirectUri = params.get("redirect_uri") || "";
|
|
316
|
-
const codeChallenge = params.get("code_challenge") || "";
|
|
317
|
-
const codeChallengeMethod = params.get("code_challenge_method") || "";
|
|
318
|
-
const state = params.get("state") || "";
|
|
319
|
-
const resource = params.get("resource") || "";
|
|
320
|
-
// Validate required params
|
|
321
|
-
if (responseType !== "code") {
|
|
322
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
323
|
-
res.end(JSON.stringify({ error: "unsupported_response_type" }));
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
if (!clientId || !redirectUri || !codeChallenge) {
|
|
327
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
328
|
-
res.end(JSON.stringify({ error: "invalid_request", error_description: "Missing required parameters" }));
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
|
|
332
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
333
|
-
res.end(JSON.stringify({ error: "invalid_request", error_description: "Only S256 code challenge method is supported" }));
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
const client = getClient(clientId);
|
|
337
|
-
if (!client) {
|
|
338
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
339
|
-
res.end(JSON.stringify({ error: "invalid_request", error_description: "Unknown client_id" }));
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
if (!client.redirectUris.includes(redirectUri)) {
|
|
343
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
344
|
-
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri not registered" }));
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
// Serialize OAuth params for the hidden form field
|
|
348
|
-
const oauthParams = new URLSearchParams({
|
|
349
|
-
client_id: clientId,
|
|
350
|
-
redirect_uri: redirectUri,
|
|
351
|
-
code_challenge: codeChallenge,
|
|
352
|
-
state,
|
|
353
|
-
resource,
|
|
354
|
-
}).toString();
|
|
355
|
-
const cookieHeader = req.headers.cookie || "";
|
|
356
|
-
const hasPairedSession = validateSessionCookie(cookieHeader, apiKey);
|
|
357
|
-
const html = renderAuthorizePage(client.clientName, oauthParams, hasPairedSession);
|
|
358
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
359
|
-
res.end(html);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
// --- OAuth Authorize (POST — process approval/denial) ---
|
|
363
|
-
if (rawPath === "/authorize" && req.method === "POST") {
|
|
364
|
-
try {
|
|
365
|
-
const formData = await parseFormBody(req);
|
|
366
|
-
const action = formData.get("action") || "";
|
|
367
|
-
const oauthParamsStr = formData.get("oauth_params") || "";
|
|
368
|
-
const pairingCode = formData.get("pairing_code") || "";
|
|
369
|
-
const oauthParams = new URLSearchParams(oauthParamsStr);
|
|
370
|
-
const clientId = oauthParams.get("client_id") || "";
|
|
371
|
-
const redirectUri = oauthParams.get("redirect_uri") || "";
|
|
372
|
-
const codeChallenge = oauthParams.get("code_challenge") || "";
|
|
373
|
-
const state = oauthParams.get("state") || "";
|
|
374
|
-
const resource = oauthParams.get("resource") || "";
|
|
375
|
-
// Validate client and redirect URI before any redirect to prevent open redirect
|
|
376
|
-
const client = getClient(clientId);
|
|
377
|
-
if (!client?.redirectUris.includes(redirectUri)) {
|
|
378
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
379
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Build redirect URL using URL API to safely merge query params
|
|
383
|
-
const buildRedirect = (params) => {
|
|
384
|
-
const url = new URL(redirectUri);
|
|
385
|
-
for (const [key, value] of Object.entries(params)) {
|
|
386
|
-
url.searchParams.set(key, value);
|
|
387
|
-
}
|
|
388
|
-
if (state) {
|
|
389
|
-
url.searchParams.set("state", state);
|
|
390
|
-
}
|
|
391
|
-
return url.toString();
|
|
392
|
-
};
|
|
393
|
-
// Deny action
|
|
394
|
-
if (action === "deny") {
|
|
395
|
-
res.writeHead(302, { Location: buildRedirect({ error: "access_denied" }) });
|
|
396
|
-
res.end();
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
// Check session — if no session, require pairing code
|
|
400
|
-
const cookieHeader = req.headers.cookie || "";
|
|
401
|
-
let hasPairedSession = validateSessionCookie(cookieHeader, apiKey);
|
|
402
|
-
const responseHeaders = {};
|
|
403
|
-
if (!hasPairedSession) {
|
|
404
|
-
if (!pairingCode) {
|
|
405
|
-
const html = renderAuthorizePage(client.clientName, oauthParamsStr, false, "Pairing code is required.");
|
|
406
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
407
|
-
res.end(html);
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const remoteIp = getRemoteIp(req);
|
|
411
|
-
if (!redeemPairingCode(pairingCode, remoteIp)) {
|
|
412
|
-
const html = renderAuthorizePage(client.clientName, oauthParamsStr, false, "Invalid or expired pairing code.");
|
|
413
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
414
|
-
res.end(html);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
// Pairing succeeded — also create a browser session
|
|
418
|
-
const setCookie = createSession(apiKey, { secure: allowNetwork });
|
|
419
|
-
responseHeaders["Set-Cookie"] = setCookie;
|
|
420
|
-
hasPairedSession = true;
|
|
421
|
-
}
|
|
422
|
-
// Approved — create authorization code
|
|
423
|
-
const authCode = createAuthorizationCode(clientId, redirectUri, codeChallenge, resource);
|
|
424
|
-
const redirectUrl = buildRedirect({ code: authCode });
|
|
425
|
-
res.writeHead(302, {
|
|
426
|
-
...responseHeaders,
|
|
427
|
-
Location: redirectUrl,
|
|
428
|
-
});
|
|
429
|
-
res.end();
|
|
430
|
-
}
|
|
431
|
-
catch {
|
|
432
|
-
res.writeHead(400);
|
|
433
|
-
res.end("Bad Request");
|
|
434
|
-
}
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
// --- OAuth Token endpoint ---
|
|
438
|
-
if (rawPath === "/token" && req.method === "POST") {
|
|
439
|
-
try {
|
|
440
|
-
const formData = await parseFormBody(req);
|
|
441
|
-
const grantType = formData.get("grant_type") || "";
|
|
442
|
-
if (grantType === "authorization_code") {
|
|
443
|
-
const code = formData.get("code") || "";
|
|
444
|
-
const clientId = formData.get("client_id") || "";
|
|
445
|
-
const redirectUri = formData.get("redirect_uri") || "";
|
|
446
|
-
const codeVerifier = formData.get("code_verifier") || "";
|
|
447
|
-
const resource = formData.get("resource") || "";
|
|
448
|
-
const authCodeRecord = consumeAuthorizationCode(code, clientId, redirectUri, codeVerifier, resource);
|
|
449
|
-
if (!authCodeRecord) {
|
|
450
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
451
|
-
res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const accessToken = createOAuthAccessToken(clientId, resource, apiKey);
|
|
455
|
-
const refreshToken = createRefreshToken(clientId, resource);
|
|
456
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
457
|
-
res.end(JSON.stringify({
|
|
458
|
-
access_token: accessToken,
|
|
459
|
-
token_type: "Bearer",
|
|
460
|
-
expires_in: Math.floor(OAUTH_ACCESS_TOKEN_TTL_MS / 1000),
|
|
461
|
-
refresh_token: refreshToken,
|
|
462
|
-
}));
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (grantType === "refresh_token") {
|
|
466
|
-
const refreshToken = formData.get("refresh_token") || "";
|
|
467
|
-
const clientId = formData.get("client_id") || "";
|
|
468
|
-
const refreshRecord = consumeRefreshToken(refreshToken, clientId);
|
|
469
|
-
if (!refreshRecord) {
|
|
470
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
471
|
-
res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
const accessToken = createOAuthAccessToken(clientId, refreshRecord.resource, apiKey);
|
|
475
|
-
const newRefreshToken = createRefreshToken(clientId, refreshRecord.resource);
|
|
476
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
477
|
-
res.end(JSON.stringify({
|
|
478
|
-
access_token: accessToken,
|
|
479
|
-
token_type: "Bearer",
|
|
480
|
-
expires_in: Math.floor(OAUTH_ACCESS_TOKEN_TTL_MS / 1000),
|
|
481
|
-
refresh_token: newRefreshToken,
|
|
482
|
-
}));
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
486
|
-
res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
487
|
-
}
|
|
488
|
-
catch {
|
|
489
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
490
|
-
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
491
|
-
}
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
// --- Pairing endpoint ---
|
|
495
|
-
if (rawPath === "/pair") {
|
|
496
|
-
const params = new URLSearchParams(queryString);
|
|
497
|
-
const code = params.get("code");
|
|
498
|
-
if (code) {
|
|
499
|
-
const remoteIp = getRemoteIp(req);
|
|
500
|
-
if (redeemPairingCode(code, remoteIp)) {
|
|
501
|
-
const setCookie = createSession(apiKey, { secure: allowNetwork });
|
|
502
|
-
res.writeHead(302, {
|
|
503
|
-
Location: "/",
|
|
504
|
-
"Set-Cookie": setCookie,
|
|
505
|
-
});
|
|
506
|
-
res.end();
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
// Invalid or expired code — show pairing page with error
|
|
510
|
-
const html = renderPairingPage("Invalid or expired pairing code. Try again.");
|
|
511
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
512
|
-
res.end(html);
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
// No code provided — show the pairing form
|
|
516
|
-
const html = renderPairingPage();
|
|
517
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
518
|
-
res.end(html);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
// --- Public static assets (favicons, manifest) — no session required ---
|
|
522
|
-
if (PUBLIC_ASSETS.has(rawPath)) {
|
|
523
|
-
serveStaticFile(req, res, rawPath);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
// --- All other routes require a valid session cookie or Bearer token ---
|
|
527
|
-
const cookieHeader = req.headers.cookie || "";
|
|
528
|
-
const authHeader = req.headers.authorization || "";
|
|
529
|
-
const bearerToken = authHeader.replace(/^Bearer\s+/i, "");
|
|
530
|
-
const hasValidSession = validateSessionCookie(cookieHeader, apiKey);
|
|
531
|
-
const hasValidBearer = bearerToken.length > 0 && verifyApiKey(bearerToken);
|
|
532
|
-
// --- ConnectRPC routes (Connect protocol over HTTP/1.1) ---
|
|
533
|
-
if (rawPath.startsWith("/grackle.Grackle/")) {
|
|
534
|
-
if (!hasValidSession && !hasValidBearer) {
|
|
535
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
536
|
-
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
webConnectHandler(req, res);
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
if (!hasValidSession) {
|
|
543
|
-
res.writeHead(302, { Location: "/pair" });
|
|
544
|
-
res.end();
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
serveStaticFile(req, res, rawPath);
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
/** Whether a bind address is a wildcard (binds all interfaces). */
|
|
551
|
-
function isWildcardAddress(host) {
|
|
552
|
-
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0";
|
|
553
|
-
}
|
|
554
33
|
/** Manager for local PowerLine lifecycle (start, stop, auto-restart). */
|
|
555
34
|
let localPowerLineManager;
|
|
556
35
|
async function main() {
|
|
@@ -734,7 +213,12 @@ async function main() {
|
|
|
734
213
|
// --- Web + WebSocket server (HTTP/1.1) ---
|
|
735
214
|
const webPort = parseInt(process.env.GRACKLE_WEB_PORT || String(DEFAULT_WEB_PORT), 10);
|
|
736
215
|
const mcpPort = parseInt(process.env.GRACKLE_MCP_PORT || String(DEFAULT_MCP_PORT), 10);
|
|
737
|
-
const webServer =
|
|
216
|
+
const webServer = createWebServer({
|
|
217
|
+
apiKey,
|
|
218
|
+
webPort,
|
|
219
|
+
bindHost,
|
|
220
|
+
connectRoutes: registerGrackleRoutes,
|
|
221
|
+
});
|
|
738
222
|
createWsBridge(webServer, {
|
|
739
223
|
verifyApiKey,
|
|
740
224
|
validateCookie: (cookieHeader) => validateSessionCookie(cookieHeader, apiKey),
|