@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.
Files changed (83) hide show
  1. package/dist/__mocks__/adapter-manager.d.ts +9 -0
  2. package/dist/__mocks__/adapter-manager.d.ts.map +1 -0
  3. package/dist/__mocks__/adapter-manager.js +10 -0
  4. package/dist/__mocks__/adapter-manager.js.map +1 -0
  5. package/dist/__mocks__/auto-reconnect.d.ts +5 -0
  6. package/dist/__mocks__/auto-reconnect.d.ts.map +1 -0
  7. package/dist/__mocks__/auto-reconnect.js +6 -0
  8. package/dist/__mocks__/auto-reconnect.js.map +1 -0
  9. package/dist/__mocks__/event-bus.d.ts +4 -0
  10. package/dist/__mocks__/event-bus.d.ts.map +1 -0
  11. package/dist/__mocks__/event-bus.js +5 -0
  12. package/dist/__mocks__/event-bus.js.map +1 -0
  13. package/dist/__mocks__/event-processor.d.ts +4 -0
  14. package/dist/__mocks__/event-processor.d.ts.map +1 -0
  15. package/dist/__mocks__/event-processor.js +5 -0
  16. package/dist/__mocks__/event-processor.js.map +1 -0
  17. package/dist/__mocks__/github-import.d.ts +7 -0
  18. package/dist/__mocks__/github-import.d.ts.map +1 -0
  19. package/dist/__mocks__/github-import.js +8 -0
  20. package/dist/__mocks__/github-import.js.map +1 -0
  21. package/dist/__mocks__/knowledge-init.d.ts +4 -0
  22. package/dist/__mocks__/knowledge-init.d.ts.map +1 -0
  23. package/dist/__mocks__/knowledge-init.js +5 -0
  24. package/dist/__mocks__/knowledge-init.js.map +1 -0
  25. package/dist/__mocks__/lifecycle.d.ts +4 -0
  26. package/dist/__mocks__/lifecycle.d.ts.map +1 -0
  27. package/dist/__mocks__/lifecycle.js +5 -0
  28. package/dist/__mocks__/lifecycle.js.map +1 -0
  29. package/dist/__mocks__/log-writer.d.ts +7 -0
  30. package/dist/__mocks__/log-writer.d.ts.map +1 -0
  31. package/dist/__mocks__/log-writer.js +8 -0
  32. package/dist/__mocks__/log-writer.js.map +1 -0
  33. package/dist/__mocks__/logger.d.ts +7 -0
  34. package/dist/__mocks__/logger.d.ts.map +1 -0
  35. package/dist/__mocks__/logger.js +3 -0
  36. package/dist/__mocks__/logger.js.map +1 -0
  37. package/dist/__mocks__/pipe-delivery.d.ts +6 -0
  38. package/dist/__mocks__/pipe-delivery.d.ts.map +1 -0
  39. package/dist/__mocks__/pipe-delivery.js +7 -0
  40. package/dist/__mocks__/pipe-delivery.js.map +1 -0
  41. package/dist/__mocks__/processor-registry.d.ts +6 -0
  42. package/dist/__mocks__/processor-registry.d.ts.map +1 -0
  43. package/dist/__mocks__/processor-registry.js +7 -0
  44. package/dist/__mocks__/processor-registry.js.map +1 -0
  45. package/dist/__mocks__/reanimate-agent.d.ts +2 -0
  46. package/dist/__mocks__/reanimate-agent.d.ts.map +1 -0
  47. package/dist/__mocks__/reanimate-agent.js +3 -0
  48. package/dist/__mocks__/reanimate-agent.js.map +1 -0
  49. package/dist/__mocks__/session-recovery.d.ts +3 -0
  50. package/dist/__mocks__/session-recovery.d.ts.map +1 -0
  51. package/dist/__mocks__/session-recovery.js +4 -0
  52. package/dist/__mocks__/session-recovery.js.map +1 -0
  53. package/dist/__mocks__/stream-hub.d.ts +9 -0
  54. package/dist/__mocks__/stream-hub.d.ts.map +1 -0
  55. package/dist/__mocks__/stream-hub.js +10 -0
  56. package/dist/__mocks__/stream-hub.js.map +1 -0
  57. package/dist/__mocks__/stream-registry.d.ts +16 -0
  58. package/dist/__mocks__/stream-registry.d.ts.map +1 -0
  59. package/dist/__mocks__/stream-registry.js +17 -0
  60. package/dist/__mocks__/stream-registry.js.map +1 -0
  61. package/dist/__mocks__/token-push.d.ts +5 -0
  62. package/dist/__mocks__/token-push.d.ts.map +1 -0
  63. package/dist/__mocks__/token-push.js +6 -0
  64. package/dist/__mocks__/token-push.js.map +1 -0
  65. package/dist/__mocks__/utils/exec.d.ts +3 -0
  66. package/dist/__mocks__/utils/exec.d.ts.map +1 -0
  67. package/dist/__mocks__/utils/exec.js +3 -0
  68. package/dist/__mocks__/utils/exec.js.map +1 -0
  69. package/dist/__mocks__/utils/format-gh-error.d.ts +3 -0
  70. package/dist/__mocks__/utils/format-gh-error.d.ts.map +1 -0
  71. package/dist/__mocks__/utils/format-gh-error.js +3 -0
  72. package/dist/__mocks__/utils/format-gh-error.js.map +1 -0
  73. package/dist/__mocks__/utils/network.d.ts +3 -0
  74. package/dist/__mocks__/utils/network.d.ts.map +1 -0
  75. package/dist/__mocks__/utils/network.js +3 -0
  76. package/dist/__mocks__/utils/network.js.map +1 -0
  77. package/dist/index.js +9 -525
  78. package/dist/index.js.map +1 -1
  79. package/dist/test-utils/integration-setup.d.ts +11 -0
  80. package/dist/test-utils/integration-setup.d.ts.map +1 -0
  81. package/dist/test-utils/integration-setup.js +32 -0
  82. package/dist/test-utils/integration-setup.js.map +1 -0
  83. 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 { readFileSync, existsSync } from "node:fs";
27
- import { join, dirname, extname, normalize, resolve, relative } from "node:path";
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
- const MIME_TYPES = {
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 &amp; 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, "&amp;")
119
- .replace(/</g, "&lt;")
120
- .replace(/>/g, "&gt;")
121
- .replace(/"/g, "&quot;")
122
- .replace(/'/g, "&#x27;");
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 = http.createServer(createWebHandler(apiKey, webPort, bindHost));
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),