@synsci/thesis 0.1.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.
@@ -0,0 +1,548 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { spawn } from "node:child_process";
4
+
5
+ const DEFAULT_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const MIN_POLL_DELAY_MS = 1000;
7
+ const MAX_POLL_DELAY_MS = 10_000;
8
+
9
+ function openUrlInBrowser(url) {
10
+ const platform = process.platform;
11
+ if (platform === "darwin") {
12
+ return spawn("open", [url], { stdio: "ignore", detached: true });
13
+ }
14
+ if (platform === "win32") {
15
+ return spawn("cmd", ["/c", "start", "", url], {
16
+ stdio: "ignore",
17
+ detached: true,
18
+ });
19
+ }
20
+ return spawn("xdg-open", [url], { stdio: "ignore", detached: true });
21
+ }
22
+
23
+ function renderCallbackPage({ ok, message }) {
24
+ const title = ok ? "Connected to Thesis" : "Connection Failed";
25
+ const icon = ok
26
+ ? `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e7e5e4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>`
27
+ : `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
28
+ return `<!doctype html>
29
+ <html>
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <title>${title}</title>
33
+ <link rel="preconnect" href="https://fonts.googleapis.com">
34
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
35
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
36
+ <style>
37
+ * { margin: 0; padding: 0; box-sizing: border-box; }
38
+ body {
39
+ font-family: 'Space Grotesk', system-ui, sans-serif;
40
+ background: #0a0a0a;
41
+ color: #e7e5e4;
42
+ min-height: 100vh;
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ }
47
+ .container {
48
+ text-align: center;
49
+ padding: 3rem;
50
+ }
51
+ .icon { margin-bottom: 1.5rem; }
52
+ .brand {
53
+ font-size: 0.75rem;
54
+ font-weight: 500;
55
+ letter-spacing: 0.15em;
56
+ text-transform: uppercase;
57
+ color: #78716c;
58
+ margin-bottom: 1rem;
59
+ }
60
+ h1 {
61
+ font-size: 1.5rem;
62
+ font-weight: 600;
63
+ margin-bottom: 1rem;
64
+ color: ${ok ? "#e7e5e4" : "#dc2626"};
65
+ }
66
+ .message {
67
+ font-size: 0.875rem;
68
+ color: #a8a29e;
69
+ line-height: 1.6;
70
+ }
71
+ .hint {
72
+ font-size: 0.75rem;
73
+ color: #57534e;
74
+ margin-top: 2rem;
75
+ }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <div class="container">
80
+ <div class="icon">${icon}</div>
81
+ <div class="brand">Thesis</div>
82
+ <h1>${title}</h1>
83
+ <p class="message">${message}</p>
84
+ <p class="hint">You can close this tab and return to your terminal.</p>
85
+ </div>
86
+ </body>
87
+ </html>`;
88
+ }
89
+
90
+ function resolveErrorMessage(payload, fallback) {
91
+ if (!payload || typeof payload !== "object") {
92
+ return fallback;
93
+ }
94
+ const detail = payload.detail;
95
+ if (typeof detail === "string" && detail.trim()) {
96
+ return detail.trim();
97
+ }
98
+ if (detail && typeof detail === "object") {
99
+ const message = detail.message;
100
+ if (typeof message === "string" && message.trim()) {
101
+ return message.trim();
102
+ }
103
+ }
104
+ return fallback;
105
+ }
106
+
107
+ async function parseJsonResponse(response) {
108
+ let payload = {};
109
+ try {
110
+ payload = await response.json();
111
+ } catch {
112
+ payload = {};
113
+ }
114
+ return payload;
115
+ }
116
+
117
+ function nonEmptyString(value) {
118
+ if (typeof value !== "string") return null;
119
+ const candidate = value.trim();
120
+ return candidate ? candidate : null;
121
+ }
122
+
123
+ async function redeemSetupExchangeToken({
124
+ baseUrl,
125
+ exchangeToken,
126
+ state,
127
+ redirectUri,
128
+ }) {
129
+ const redeemUrl = new URL(
130
+ "/api/auth/mcp-api-keys/setup-exchange/redeem",
131
+ baseUrl,
132
+ );
133
+ const response = await fetch(redeemUrl, {
134
+ method: "POST",
135
+ headers: {
136
+ "content-type": "application/json",
137
+ "Idempotency-Key": `setup-redeem:${state}`,
138
+ },
139
+ body: JSON.stringify({
140
+ exchange_token: exchangeToken,
141
+ state,
142
+ redirect_uri: redirectUri,
143
+ }),
144
+ });
145
+ const payload = await parseJsonResponse(response);
146
+ if (!response.ok) {
147
+ const detail = resolveErrorMessage(
148
+ payload,
149
+ "Setup exchange redemption failed.",
150
+ );
151
+ throw new Error(detail);
152
+ }
153
+ const key = nonEmptyString(payload?.key);
154
+ if (!key) {
155
+ throw new Error("Setup exchange response missing API key.");
156
+ }
157
+ return { apiKey: key };
158
+ }
159
+
160
+ async function startSetupDeviceSession({
161
+ baseUrl,
162
+ keyName,
163
+ verificationBaseUrl,
164
+ }) {
165
+ const startUrl = new URL(
166
+ "/api/auth/mcp-api-keys/setup-device/start",
167
+ baseUrl,
168
+ );
169
+ const response = await fetch(startUrl, {
170
+ method: "POST",
171
+ headers: {
172
+ "content-type": "application/json",
173
+ "Idempotency-Key": `setup-device-start:${crypto.randomUUID()}`,
174
+ },
175
+ body: JSON.stringify({
176
+ name: keyName,
177
+ verification_base_url: verificationBaseUrl,
178
+ }),
179
+ });
180
+ const payload = await parseJsonResponse(response);
181
+ if (!response.ok) {
182
+ const detail = resolveErrorMessage(
183
+ payload,
184
+ "Failed to start setup device flow.",
185
+ );
186
+ throw new Error(detail);
187
+ }
188
+
189
+ const deviceCode = nonEmptyString(payload.device_code);
190
+ const userCode = nonEmptyString(payload.user_code);
191
+ const verificationUri = nonEmptyString(payload.verification_uri);
192
+ const verificationUriComplete = nonEmptyString(
193
+ payload.verification_uri_complete,
194
+ );
195
+ const pollIntervalSeconds = Number(payload.poll_interval_seconds);
196
+ const expiresInSeconds = Number(payload.expires_in_seconds);
197
+
198
+ if (
199
+ !deviceCode ||
200
+ !userCode ||
201
+ !verificationUri ||
202
+ !verificationUriComplete
203
+ ) {
204
+ throw new Error(
205
+ "Setup device session response missing required fields.",
206
+ );
207
+ }
208
+ if (
209
+ !Number.isFinite(pollIntervalSeconds) ||
210
+ pollIntervalSeconds <= 0 ||
211
+ !Number.isFinite(expiresInSeconds) ||
212
+ expiresInSeconds <= 0
213
+ ) {
214
+ throw new Error(
215
+ "Setup device session response contained invalid timing values.",
216
+ );
217
+ }
218
+
219
+ return {
220
+ deviceCode,
221
+ userCode,
222
+ verificationUri,
223
+ verificationUriComplete,
224
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
225
+ expiresInSeconds: Math.floor(expiresInSeconds),
226
+ };
227
+ }
228
+
229
+ async function pollSetupDeviceSession({ baseUrl, userCode, deviceCode }) {
230
+ const pollUrl = new URL(
231
+ "/api/auth/mcp-api-keys/setup-device/poll",
232
+ baseUrl,
233
+ );
234
+ const response = await fetch(pollUrl, {
235
+ method: "POST",
236
+ headers: {
237
+ "content-type": "application/json",
238
+ "Idempotency-Key": `setup-device-poll:${deviceCode}:${Date.now()}`,
239
+ },
240
+ body: JSON.stringify({
241
+ user_code: userCode,
242
+ device_code: deviceCode,
243
+ }),
244
+ });
245
+ if (response.status === 404) {
246
+ return { status: "expired", pollIntervalSeconds: 0 };
247
+ }
248
+ const payload = await parseJsonResponse(response);
249
+ if (!response.ok) {
250
+ const detail = resolveErrorMessage(
251
+ payload,
252
+ "Setup device polling failed.",
253
+ );
254
+ throw new Error(detail);
255
+ }
256
+
257
+ const status = nonEmptyString(payload.status);
258
+ const pollIntervalSeconds = Number(payload.poll_interval_seconds);
259
+ if (
260
+ !status ||
261
+ !Number.isFinite(pollIntervalSeconds) ||
262
+ pollIntervalSeconds <= 0
263
+ ) {
264
+ throw new Error(
265
+ "Setup device poll response missing required status fields.",
266
+ );
267
+ }
268
+ if (status === "pending") {
269
+ return {
270
+ status,
271
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
272
+ };
273
+ }
274
+ if (status === "approved") {
275
+ const key = nonEmptyString(payload.key);
276
+ if (!key) {
277
+ throw new Error("Setup device poll response missing API key.");
278
+ }
279
+ return {
280
+ status,
281
+ pollIntervalSeconds: Math.floor(pollIntervalSeconds),
282
+ apiKey: key,
283
+ };
284
+ }
285
+ throw new Error(`Unsupported setup device session status: ${status}`);
286
+ }
287
+
288
+ function pollDelayMs({ pollIntervalSeconds, attempt }) {
289
+ const baseMs = Math.max(
290
+ MIN_POLL_DELAY_MS,
291
+ Math.floor(pollIntervalSeconds * 1000),
292
+ );
293
+ const rampMs = Math.min(5000, attempt * 250);
294
+ const jitterMs = Math.floor(Math.random() * 250);
295
+ return Math.min(MAX_POLL_DELAY_MS, baseMs + rampMs + jitterMs);
296
+ }
297
+
298
+ function defaultSleep(ms) {
299
+ return new Promise((resolve) => {
300
+ setTimeout(resolve, ms);
301
+ });
302
+ }
303
+
304
+ function launchBrowser(openUrl, url) {
305
+ try {
306
+ const child = openUrl(url);
307
+ if (child && typeof child.once === "function") {
308
+ child.once("error", () => {
309
+ // URL is printed to stdout; opener failures should not abort setup.
310
+ });
311
+ }
312
+ if (child && typeof child.unref === "function") {
313
+ child.unref();
314
+ }
315
+ } catch {
316
+ // User can still open URL manually.
317
+ }
318
+ }
319
+
320
+ export function resolveSetupAuthMode({ requestedMode, env = process.env }) {
321
+ const normalizedRequested = String(requestedMode || "auto")
322
+ .trim()
323
+ .toLowerCase();
324
+
325
+ if (
326
+ normalizedRequested === "device" ||
327
+ normalizedRequested === "loopback"
328
+ ) {
329
+ return normalizedRequested;
330
+ }
331
+
332
+ const isRemoteShell =
333
+ nonEmptyString(env.SSH_CONNECTION) ||
334
+ nonEmptyString(env.SSH_CLIENT) ||
335
+ nonEmptyString(env.SSH_TTY);
336
+ return isRemoteShell ? "device" : "loopback";
337
+ }
338
+
339
+ export async function acquireApiKeyViaBrowserBridge({
340
+ baseUrl,
341
+ keyName,
342
+ timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
343
+ openUrl = openUrlInBrowser,
344
+ }) {
345
+ const state = crypto.randomUUID();
346
+
347
+ return await new Promise((resolve, reject) => {
348
+ let settled = false;
349
+ let timeout = null;
350
+ let server = null;
351
+ let callbackUrl = "";
352
+
353
+ const cleanup = () => {
354
+ if (timeout) {
355
+ clearTimeout(timeout);
356
+ timeout = null;
357
+ }
358
+ if (server) {
359
+ server.close();
360
+ server.closeAllConnections?.();
361
+ server.closeIdleConnections?.();
362
+ server = null;
363
+ }
364
+ };
365
+
366
+ const fail = (error) => {
367
+ if (settled) return;
368
+ settled = true;
369
+ cleanup();
370
+ reject(error);
371
+ };
372
+
373
+ const succeed = (result) => {
374
+ if (settled) return;
375
+ settled = true;
376
+ cleanup();
377
+ resolve(result);
378
+ };
379
+
380
+ server = http.createServer(async (req, res) => {
381
+ const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
382
+ if (reqUrl.pathname !== "/callback") {
383
+ res.writeHead(404, { "Content-Type": "text/plain" });
384
+ res.end("Not Found");
385
+ return;
386
+ }
387
+
388
+ const callbackState = (
389
+ reqUrl.searchParams.get("state") || ""
390
+ ).trim();
391
+ const exchangeToken = (
392
+ reqUrl.searchParams.get("exchange_token") || ""
393
+ ).trim();
394
+ const error = (reqUrl.searchParams.get("error") || "").trim();
395
+
396
+ if (callbackState !== state) {
397
+ res.writeHead(400, {
398
+ "Content-Type": "text/html; charset=utf-8",
399
+ });
400
+ res.end(
401
+ renderCallbackPage({
402
+ ok: false,
403
+ message: "State mismatch. Please retry setup.",
404
+ }),
405
+ );
406
+ fail(new Error("Setup callback state mismatch."));
407
+ return;
408
+ }
409
+
410
+ if (error) {
411
+ res.writeHead(400, {
412
+ "Content-Type": "text/html; charset=utf-8",
413
+ });
414
+ res.end(renderCallbackPage({ ok: false, message: error }));
415
+ fail(new Error(error));
416
+ return;
417
+ }
418
+
419
+ if (!exchangeToken) {
420
+ res.writeHead(400, {
421
+ "Content-Type": "text/html; charset=utf-8",
422
+ });
423
+ res.end(
424
+ renderCallbackPage({
425
+ ok: false,
426
+ message: "No setup exchange token was returned.",
427
+ }),
428
+ );
429
+ fail(new Error("Setup callback missing exchange token."));
430
+ return;
431
+ }
432
+
433
+ let redeemed;
434
+ try {
435
+ redeemed = await redeemSetupExchangeToken({
436
+ baseUrl,
437
+ exchangeToken,
438
+ state,
439
+ redirectUri: callbackUrl,
440
+ });
441
+ } catch (err) {
442
+ const message =
443
+ err instanceof Error
444
+ ? err.message
445
+ : "Failed to redeem setup exchange.";
446
+ res.writeHead(400, {
447
+ "Content-Type": "text/html; charset=utf-8",
448
+ });
449
+ res.end(renderCallbackPage({ ok: false, message }));
450
+ fail(new Error(message));
451
+ return;
452
+ }
453
+
454
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
455
+ res.end(
456
+ renderCallbackPage({
457
+ ok: true,
458
+ message: "Thesis MCP was authorized successfully.",
459
+ }),
460
+ );
461
+ succeed({ apiKey: redeemed.apiKey });
462
+ });
463
+
464
+ server.once("error", (error) => {
465
+ fail(error);
466
+ });
467
+
468
+ server.listen(0, "127.0.0.1", () => {
469
+ const address = server.address();
470
+ if (!address || typeof address === "string") {
471
+ fail(new Error("Failed to allocate local callback port."));
472
+ return;
473
+ }
474
+
475
+ callbackUrl = `http://127.0.0.1:${address.port}/callback`;
476
+ const setupUrl = new URL("/auth/mcp/setup", baseUrl);
477
+ setupUrl.searchParams.set("state", state);
478
+ setupUrl.searchParams.set("redirect_uri", callbackUrl);
479
+ setupUrl.searchParams.set("name", keyName);
480
+
481
+ console.log(
482
+ "Opening browser for Thesis login and key creation...",
483
+ );
484
+ console.log(
485
+ `If it does not open, use this URL:\n${setupUrl.toString()}\n`,
486
+ );
487
+
488
+ launchBrowser(openUrl, setupUrl.toString());
489
+ });
490
+
491
+ timeout = setTimeout(() => {
492
+ fail(new Error("Timed out waiting for browser authorization."));
493
+ }, timeoutMs);
494
+ });
495
+ }
496
+
497
+ export async function acquireApiKeyViaDeviceFlow({
498
+ baseUrl,
499
+ keyName,
500
+ verificationBaseUrl = baseUrl,
501
+ timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
502
+ openUrl = openUrlInBrowser,
503
+ sleep = defaultSleep,
504
+ }) {
505
+ const startedAt = Date.now();
506
+ const session = await startSetupDeviceSession({
507
+ baseUrl,
508
+ keyName,
509
+ verificationBaseUrl,
510
+ });
511
+
512
+ console.log("Opening browser for Thesis device authorization...");
513
+ console.log(`Approval URL:\n${session.verificationUriComplete}\n`);
514
+ console.log(`Code: ${session.userCode}`);
515
+ console.log(
516
+ "If the page does not open automatically, open the URL above and approve this code.\n",
517
+ );
518
+
519
+ launchBrowser(openUrl, session.verificationUriComplete);
520
+
521
+ let attempt = 0;
522
+ while (Date.now() - startedAt < timeoutMs) {
523
+ // eslint-disable-next-line no-await-in-loop
524
+ const pollResult = await pollSetupDeviceSession({
525
+ baseUrl,
526
+ userCode: session.userCode,
527
+ deviceCode: session.deviceCode,
528
+ });
529
+ if (pollResult.status === "approved") {
530
+ return { apiKey: pollResult.apiKey };
531
+ }
532
+ if (pollResult.status === "expired") {
533
+ throw new Error(
534
+ "Setup device session expired. Please rerun setup.",
535
+ );
536
+ }
537
+
538
+ attempt += 1;
539
+ const delayMs = pollDelayMs({
540
+ pollIntervalSeconds: pollResult.pollIntervalSeconds,
541
+ attempt,
542
+ });
543
+ // eslint-disable-next-line no-await-in-loop
544
+ await sleep(delayMs);
545
+ }
546
+
547
+ throw new Error("Timed out waiting for device authorization.");
548
+ }