@remnic/server 9.3.767 → 9.3.769

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/index.js CHANGED
@@ -3,7 +3,685 @@
3
3
  // src/index.ts
4
4
  import fs from "fs";
5
5
  import path from "path";
6
- import { parseConfig, isOpenaiApiKeyDisabled, resolveRemnicConfigRecord, Orchestrator, EngramAccessService, EngramAccessHttpServer, initLogger, log, getAllValidTokens, getAllValidTokensCached, expandTildePath } from "@remnic/core";
6
+ import { parseConfig, isOpenaiApiKeyDisabled, resolveRemnicConfigRecord, Orchestrator, EngramAccessService, EngramAccessHttpServer, initLogger, log as log2, getAllValidTokens, getAllValidTokenEntriesCached, expandTildePath } from "@remnic/core";
7
+
8
+ // src/oauth.ts
9
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
10
+ import express from "express";
11
+ import { rateLimit } from "express-rate-limit";
12
+ import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
13
+ import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
14
+ import { createOAuthMetadata, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
15
+ import {
16
+ InvalidGrantError,
17
+ InvalidTokenError,
18
+ ServerError,
19
+ UnsupportedGrantTypeError
20
+ } from "@modelcontextprotocol/sdk/server/auth/errors.js";
21
+ import { buildTokenEntry, commitTokenEntry, getAllValidTokensCached, log } from "@remnic/core";
22
+ var ALLOWED_TOKEN_AUTH_METHODS = ["client_secret_post", "none"];
23
+ var DEFAULT_APPROVAL_TTL_SECONDS = 600;
24
+ var AUTHORIZATION_CODE_TTL_MS = 12e4;
25
+ var HIGH_ENTROPY_HEX_BYTES = 16;
26
+ var CHATGPT_CONNECTOR_ID = "chatgpt";
27
+ function isPlainObject(value) {
28
+ return !!value && typeof value === "object" && !Array.isArray(value);
29
+ }
30
+ function coerceBoolean(value, source) {
31
+ if (typeof value === "boolean") return value;
32
+ if (typeof value === "string") {
33
+ const normalized = value.trim().toLowerCase();
34
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
35
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
36
+ }
37
+ throw new Error(`Invalid ${source}: expected a boolean (got: ${JSON.stringify(value)})`);
38
+ }
39
+ function coerceNonEmptyString(value, source) {
40
+ if (typeof value !== "string" || value.trim().length === 0) {
41
+ throw new Error(`Invalid ${source}: expected a non-empty string`);
42
+ }
43
+ return value.trim();
44
+ }
45
+ function coercePositiveInteger(value, source) {
46
+ const num = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
47
+ if (typeof num !== "number" || !Number.isInteger(num) || num <= 0) {
48
+ throw new Error(`Invalid ${source}: expected a positive integer (got: ${JSON.stringify(value)})`);
49
+ }
50
+ return num;
51
+ }
52
+ function coerceAbsoluteHttpsUrl(value, source, opts) {
53
+ if (typeof value !== "string" || value.trim().length === 0) {
54
+ throw new Error(`Invalid ${source}: expected a non-empty URL string`);
55
+ }
56
+ const trimmed = value.trim();
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(trimmed);
60
+ } catch {
61
+ throw new Error(`Invalid ${source}: "${trimmed}" is not a valid URL`);
62
+ }
63
+ if (parsed.hash || parsed.search) {
64
+ throw new Error(`Invalid ${source}: URL must not include a query string or fragment`);
65
+ }
66
+ const isHttps = parsed.protocol === "https:";
67
+ const isLocalhostHttp = opts.allowHttpForLocalhost && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost");
68
+ if (!isHttps && !isLocalhostHttp) {
69
+ throw new Error(
70
+ `Invalid ${source}: must be an absolute https:// URL (http:// allowed only for 127.0.0.1/localhost)`
71
+ );
72
+ }
73
+ return trimmed;
74
+ }
75
+ function coerceTokenAuthMethod(value, source) {
76
+ if (typeof value !== "string") {
77
+ throw new Error(`Invalid ${source}: expected one of ${ALLOWED_TOKEN_AUTH_METHODS.join(", ")}`);
78
+ }
79
+ const trimmed = value.trim();
80
+ const match = ALLOWED_TOKEN_AUTH_METHODS.find((method) => method === trimmed);
81
+ if (!match) {
82
+ throw new Error(
83
+ `Invalid ${source}: "${trimmed}" is not allowed. Use one of ${ALLOWED_TOKEN_AUTH_METHODS.join(", ")}.`
84
+ );
85
+ }
86
+ return match;
87
+ }
88
+ function coerceRedirectUris(value, source) {
89
+ if (!Array.isArray(value)) {
90
+ throw new Error(`Invalid ${source}: expected an array of exact redirect URI strings`);
91
+ }
92
+ return value.map(
93
+ (entry, index) => coerceAbsoluteHttpsUrl(entry, `${source}[${index}]`, { allowHttpForLocalhost: true })
94
+ );
95
+ }
96
+ var DISABLED_OAUTH_CONFIG = {
97
+ enabled: false,
98
+ issuerUrl: "",
99
+ clientId: "",
100
+ clientSecret: "",
101
+ tokenEndpointAuthMethod: "client_secret_post",
102
+ redirectUris: [],
103
+ approvalTtlSeconds: DEFAULT_APPROVAL_TTL_SECONDS
104
+ };
105
+ function parseOAuthConfig(raw) {
106
+ if (raw === void 0) return { ...DISABLED_OAUTH_CONFIG };
107
+ if (!isPlainObject(raw)) {
108
+ throw new Error("Invalid server.oauth: expected a JSON object");
109
+ }
110
+ const input = raw;
111
+ const enabled = input.enabled === void 0 ? false : coerceBoolean(input.enabled, "server.oauth.enabled");
112
+ if (!enabled) return { ...DISABLED_OAUTH_CONFIG };
113
+ const issuerUrl = input.issuerUrl === void 0 ? "" : coerceAbsoluteHttpsUrl(input.issuerUrl, "server.oauth.issuerUrl", { allowHttpForLocalhost: true });
114
+ const clientId = input.clientId === void 0 ? "" : coerceNonEmptyString(input.clientId, "server.oauth.clientId");
115
+ const clientSecret = input.clientSecret === void 0 ? "" : coerceNonEmptyString(input.clientSecret, "server.oauth.clientSecret");
116
+ const tokenEndpointAuthMethod = input.tokenEndpointAuthMethod === void 0 ? "client_secret_post" : coerceTokenAuthMethod(input.tokenEndpointAuthMethod, "server.oauth.tokenEndpointAuthMethod");
117
+ const redirectUris = input.redirectUris === void 0 ? [] : coerceRedirectUris(input.redirectUris, "server.oauth.redirectUris");
118
+ const approvalTtlSeconds = input.approvalTtlSeconds === void 0 ? DEFAULT_APPROVAL_TTL_SECONDS : coercePositiveInteger(input.approvalTtlSeconds, "server.oauth.approvalTtlSeconds");
119
+ if (issuerUrl === "") {
120
+ throw new Error("Invalid server.oauth.issuerUrl: required when enabled (absolute https URL)");
121
+ }
122
+ if (clientId === "") {
123
+ throw new Error("Invalid server.oauth.clientId: required when enabled (non-empty string)");
124
+ }
125
+ if (tokenEndpointAuthMethod !== "none" && clientSecret === "") {
126
+ throw new Error(
127
+ `Invalid server.oauth.clientSecret: required when enabled and tokenEndpointAuthMethod is "${tokenEndpointAuthMethod}"`
128
+ );
129
+ }
130
+ return {
131
+ enabled: true,
132
+ issuerUrl,
133
+ clientId,
134
+ clientSecret,
135
+ tokenEndpointAuthMethod,
136
+ redirectUris,
137
+ approvalTtlSeconds
138
+ };
139
+ }
140
+ function readOAuthEnvOverrides() {
141
+ const overrides = {};
142
+ if (process.env.REMNIC_OAUTH_ENABLED !== void 0) overrides.enabled = process.env.REMNIC_OAUTH_ENABLED;
143
+ if (process.env.REMNIC_OAUTH_ISSUER_URL !== void 0) overrides.issuerUrl = process.env.REMNIC_OAUTH_ISSUER_URL;
144
+ if (process.env.REMNIC_OAUTH_CLIENT_ID !== void 0) overrides.clientId = process.env.REMNIC_OAUTH_CLIENT_ID;
145
+ if (process.env.REMNIC_OAUTH_CLIENT_SECRET !== void 0) {
146
+ overrides.clientSecret = process.env.REMNIC_OAUTH_CLIENT_SECRET;
147
+ }
148
+ if (process.env.REMNIC_OAUTH_TOKEN_AUTH_METHOD !== void 0) {
149
+ overrides.tokenEndpointAuthMethod = process.env.REMNIC_OAUTH_TOKEN_AUTH_METHOD;
150
+ }
151
+ if (process.env.REMNIC_OAUTH_REDIRECT_URIS !== void 0) {
152
+ overrides.redirectUris = process.env.REMNIC_OAUTH_REDIRECT_URIS.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
153
+ }
154
+ if (process.env.REMNIC_OAUTH_APPROVAL_TTL_SECONDS !== void 0) {
155
+ overrides.approvalTtlSeconds = process.env.REMNIC_OAUTH_APPROVAL_TTL_SECONDS;
156
+ }
157
+ return overrides;
158
+ }
159
+ function applyOAuthEnvOverrides(fileBlock) {
160
+ if (fileBlock !== void 0 && !isPlainObject(fileBlock)) {
161
+ return parseOAuthConfig(fileBlock);
162
+ }
163
+ const overrides = readOAuthEnvOverrides();
164
+ const merged = isPlainObject(fileBlock) ? { ...fileBlock } : {};
165
+ for (const [key, value] of Object.entries(overrides)) {
166
+ merged[key] = value;
167
+ }
168
+ return parseOAuthConfig(merged);
169
+ }
170
+ function timingSafeStringEqual(a, b) {
171
+ if (typeof a !== "string" || typeof b !== "string") return false;
172
+ const digestA = createHash("sha256").update(a, "utf8").digest();
173
+ const digestB = createHash("sha256").update(b, "utf8").digest();
174
+ return timingSafeEqual(digestA, digestB);
175
+ }
176
+ function randomHex(bytes) {
177
+ return randomBytes(bytes).toString("hex");
178
+ }
179
+ function newApprovalRef() {
180
+ return BigInt(`0x${randomHex(8)}`).toString(36);
181
+ }
182
+ var OAuthState = class {
183
+ pending = /* @__PURE__ */ new Map();
184
+ codes = /* @__PURE__ */ new Map();
185
+ config;
186
+ constructor(config) {
187
+ this.config = config;
188
+ }
189
+ get approvalTtlMs() {
190
+ return this.config.approvalTtlSeconds * 1e3;
191
+ }
192
+ /** Delete expired entries. Run before each lookup. */
193
+ gc(now) {
194
+ for (const [key, code] of this.codes) {
195
+ if (code.expiresAt < now) this.codes.delete(key);
196
+ }
197
+ for (const [key, txn] of this.pending) {
198
+ if (txn.expiresAt >= now) continue;
199
+ if (txn.outcome?.kind === "approved" && this.codes.has(txn.outcome.code)) continue;
200
+ this.pending.delete(key);
201
+ }
202
+ }
203
+ createPending(args) {
204
+ const now = Date.now();
205
+ this.gc(now);
206
+ const txn = {
207
+ txn: randomHex(HIGH_ENTROPY_HEX_BYTES),
208
+ pollSecret: randomHex(HIGH_ENTROPY_HEX_BYTES),
209
+ ref: newApprovalRef(),
210
+ clientId: args.clientId,
211
+ redirectUri: args.redirectUri,
212
+ scopes: args.scopes,
213
+ resource: args.resource,
214
+ state: args.state,
215
+ codeChallenge: args.codeChallenge,
216
+ createdAt: now,
217
+ expiresAt: now + this.approvalTtlMs
218
+ };
219
+ this.pending.set(txn.txn, txn);
220
+ return txn;
221
+ }
222
+ listPending() {
223
+ const now = Date.now();
224
+ this.gc(now);
225
+ return Array.from(this.pending.values()).filter((txn) => !txn.outcome).sort((a, b) => a.createdAt === b.createdAt ? a.txn.localeCompare(b.txn) : a.createdAt - b.createdAt);
226
+ }
227
+ findByRef(ref) {
228
+ const now = Date.now();
229
+ this.gc(now);
230
+ for (const txn of this.pending.values()) {
231
+ if (txn.ref === ref) return txn;
232
+ }
233
+ return void 0;
234
+ }
235
+ /**
236
+ * Approve a pending transaction by ref. Returns the authorization code.
237
+ * Re-approval of an already-approved txn returns the same code so the
238
+ * operator can recover from a client-side miss. Throws on
239
+ * missing/expired/denied.
240
+ */
241
+ approveByRef(ref) {
242
+ const now = Date.now();
243
+ this.gc(now);
244
+ const txn = this.findByRef(ref);
245
+ if (!txn) throw new Error("unknown or expired ref");
246
+ if (txn.outcome) {
247
+ if (txn.outcome.kind === "approved") return { code: txn.outcome.code, txn };
248
+ throw new Error("denied");
249
+ }
250
+ const code = randomHex(HIGH_ENTROPY_HEX_BYTES);
251
+ this.codes.set(code, {
252
+ code,
253
+ clientId: txn.clientId,
254
+ redirectUri: txn.redirectUri,
255
+ codeChallenge: txn.codeChallenge,
256
+ resource: txn.resource,
257
+ expiresAt: now + AUTHORIZATION_CODE_TTL_MS,
258
+ consumed: false
259
+ });
260
+ txn.outcome = { kind: "approved", code };
261
+ return { code, txn };
262
+ }
263
+ denyByRef(ref) {
264
+ const now = Date.now();
265
+ this.gc(now);
266
+ const txn = this.findByRef(ref);
267
+ if (!txn) throw new Error("unknown or expired ref");
268
+ if (txn.outcome) {
269
+ if (txn.outcome.kind === "denied") return;
270
+ throw new Error("already approved");
271
+ }
272
+ txn.outcome = { kind: "denied" };
273
+ }
274
+ /** Redirect target carrying the code (+ state) back to the client. */
275
+ buildRedirect(txn, code) {
276
+ const url = new URL(txn.redirectUri);
277
+ url.searchParams.set("code", code);
278
+ if (txn.state) url.searchParams.set("state", txn.state);
279
+ return url.toString();
280
+ }
281
+ /** Poll status; `undefined` on wrong pollSecret. */
282
+ poll(txnId, pollSecret) {
283
+ const now = Date.now();
284
+ this.gc(now);
285
+ const txn = this.pending.get(txnId);
286
+ if (!txn) return { status: "expired" };
287
+ if (!timingSafeStringEqual(txn.pollSecret, pollSecret)) return void 0;
288
+ if (!txn.outcome) {
289
+ if (txn.expiresAt < now) return { status: "expired" };
290
+ return { status: "pending" };
291
+ }
292
+ if (txn.outcome.kind === "denied") return { status: "denied" };
293
+ if (!this.peekCode(txn.outcome.code)) return { status: "expired" };
294
+ return { status: "approved", redirect: this.buildRedirect(txn, txn.outcome.code) };
295
+ }
296
+ /**
297
+ * Read a code entry without consuming it (SDK PKCE verification path).
298
+ * Returns undefined on unknown/expired/consumed.
299
+ */
300
+ peekCode(code) {
301
+ const now = Date.now();
302
+ this.gc(now);
303
+ const entry = this.codes.get(code);
304
+ if (!entry || entry.consumed || entry.expiresAt < now) return void 0;
305
+ return entry;
306
+ }
307
+ /**
308
+ * Consume a code after the SDK verified PKCE. Enforces single use and
309
+ * the client/redirect/resource bindings. Returns the entry on success,
310
+ * undefined on any mismatch.
311
+ */
312
+ takeCode(args) {
313
+ const entry = this.peekCode(args.code);
314
+ if (!entry) return void 0;
315
+ entry.consumed = true;
316
+ if (!timingSafeStringEqual(entry.clientId, args.clientId)) return void 0;
317
+ if (args.redirectUri !== void 0 && entry.redirectUri !== args.redirectUri) return void 0;
318
+ if (entry.resource !== void 0) {
319
+ if (args.resource === void 0) return void 0;
320
+ if (entry.resource !== args.resource) return void 0;
321
+ }
322
+ return entry;
323
+ }
324
+ /**
325
+ * Un-burn a code consumed by takeCode when the downstream token persist
326
+ * fails (disk full, permissions). Lets the client retry the exchange
327
+ * instead of being forced through a fresh authorize+approve. Only
328
+ * revives an entry still present and within TTL; a binding/PKCE
329
+ * mismatch is NOT revived (that is a genuine client error, and OAuth
330
+ * 2.1 revokes a code presented incorrectly).
331
+ */
332
+ reviveCode(code) {
333
+ const entry = this.codes.get(code);
334
+ if (entry && entry.expiresAt >= Date.now()) {
335
+ entry.consumed = false;
336
+ }
337
+ }
338
+ };
339
+ function escapeHtml(value) {
340
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
341
+ }
342
+ function renderApprovalPage(args) {
343
+ const { txn, issuerUrl, command } = args;
344
+ const safeRef = escapeHtml(txn.ref);
345
+ const safeClientId = escapeHtml(txn.clientId);
346
+ const safeRedirect = escapeHtml(txn.redirectUri);
347
+ const safeResource = txn.resource ? escapeHtml(txn.resource) : "";
348
+ const safeCommand = escapeHtml(command);
349
+ return `<!DOCTYPE html>
350
+ <html lang="en">
351
+ <head>
352
+ <meta charset="utf-8">
353
+ <meta name="viewport" content="width=device-width, initial-scale=1">
354
+ <title>Remnic MCP authorization</title>
355
+ <style>
356
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; color: #1a1a1a; }
357
+ h1 { font-size: 1.4rem; }
358
+ .ref { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.1rem; background: #f4f4f5; padding: 0.25rem 0.5rem; border-radius: 4px; }
359
+ details { margin: 1rem 0; padding: 0.75rem 1rem; background: #fafafa; border: 1px solid #e4e4e7; border-radius: 6px; }
360
+ summary { cursor: pointer; font-weight: 600; }
361
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; }
362
+ pre { background: #f4f4f5; padding: 0.75rem; border-radius: 4px; overflow-x: auto; }
363
+ #status { margin-top: 1.5rem; padding: 0.75rem 1rem; border-radius: 6px; font-weight: 600; }
364
+ .pending { background: #fef9c3; color: #713f12; }
365
+ .approved { background: #dcfce7; color: #14532d; }
366
+ .denied { background: #fee2e2; color: #7f1d1d; }
367
+ .expired { background: #f3f4f6; color: #374151; }
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <h1>Remnic MCP authorization pending</h1>
372
+ <p>An external application has requested access to your Remnic MCP server.
373
+ Open a terminal on the Remnic host and run:</p>
374
+ <pre>${safeCommand}</pre>
375
+ <p>Approval ref: <span class="ref">${safeRef}</span></p>
376
+ <details>
377
+ <summary>Request details</summary>
378
+ <ul>
379
+ <li>Client ID: <code>${safeClientId}</code></li>
380
+ <li>Redirect URI: <code>${safeRedirect}</code></li>
381
+ ${safeResource ? `<li>Resource: <code>${safeResource}</code></li>` : ""}
382
+ <li>Scopes: <code>${escapeHtml(txn.scopes.join(" ")) || "(none requested)"}</code></li>
383
+ </ul>
384
+ </details>
385
+ <div id="status" class="pending">Waiting for operator approval&hellip;</div>
386
+ <script>
387
+ (function () {
388
+ var statusEl = document.getElementById("status");
389
+ var txn = ${JSON.stringify(txn.txn)};
390
+ var secret = ${JSON.stringify(txn.pollSecret)};
391
+ var issuer = ${JSON.stringify(issuerUrl)};
392
+ var done = false;
393
+ function render(result) {
394
+ if (result.status === "pending") return;
395
+ done = true;
396
+ if (result.status === "approved") {
397
+ statusEl.className = "approved";
398
+ statusEl.textContent = "Approved \u2014 redirecting\\u2026";
399
+ window.location.replace(result.redirect);
400
+ } else if (result.status === "denied") {
401
+ statusEl.className = "denied";
402
+ statusEl.textContent = "Denied by operator. Return to the calling app to retry or cancel.";
403
+ } else {
404
+ statusEl.className = "expired";
405
+ statusEl.textContent = "This request expired. Return to the calling app to retry.";
406
+ }
407
+ }
408
+ function poll() {
409
+ if (done) return;
410
+ fetch(issuer + "/oauth/authorize/poll", {
411
+ method: "POST",
412
+ headers: { "content-type": "application/json" },
413
+ body: JSON.stringify({ txn: txn, pollSecret: secret }),
414
+ })
415
+ .then(function (res) {
416
+ if (!res.ok) throw new Error("poll failed");
417
+ return res.json();
418
+ })
419
+ .then(render)
420
+ .catch(function () { /* transient; next interval retries */ });
421
+ }
422
+ poll();
423
+ setInterval(poll, 3000);
424
+ })();
425
+ </script>
426
+ </body>
427
+ </html>`;
428
+ }
429
+ var RemnicOAuthProvider = class {
430
+ config;
431
+ state;
432
+ staticClient;
433
+ /** Token-store override for tests; production uses the default path. */
434
+ tokensPath;
435
+ constructor(config, state, tokensPath) {
436
+ this.config = config;
437
+ this.state = state;
438
+ this.tokensPath = tokensPath;
439
+ this.staticClient = {
440
+ client_id: config.clientId,
441
+ // With `none`, the client is public: no secret is stored, so the
442
+ // SDK's client-auth middleware will not demand one. Otherwise the
443
+ // secret is REQUIRED on every token request.
444
+ ...config.tokenEndpointAuthMethod === "none" ? {} : { client_secret: config.clientSecret },
445
+ redirect_uris: config.redirectUris,
446
+ token_endpoint_auth_method: config.tokenEndpointAuthMethod,
447
+ grant_types: ["authorization_code"],
448
+ response_types: ["code"]
449
+ };
450
+ }
451
+ get clientsStore() {
452
+ const client = this.staticClient;
453
+ return {
454
+ getClient: (clientId) => clientId === client.client_id ? client : void 0
455
+ // No registerClient: dynamic client registration is deliberately
456
+ // NOT supported — the single client is pre-registered via config.
457
+ };
458
+ }
459
+ async authorize(client, params, res) {
460
+ const txn = this.state.createPending({
461
+ clientId: client.client_id,
462
+ redirectUri: params.redirectUri,
463
+ scopes: params.scopes ?? [],
464
+ resource: params.resource?.href,
465
+ state: params.state,
466
+ codeChallenge: params.codeChallenge
467
+ });
468
+ log.info(
469
+ `OAuth authorization pending: ref=${txn.ref} client=${txn.clientId} redirect=${txn.redirectUri} \u2014 run \`remnic oauth approve ${txn.ref}\` to approve`
470
+ );
471
+ res.status(200).type("html").send(
472
+ renderApprovalPage({
473
+ txn,
474
+ issuerUrl: this.config.issuerUrl.replace(/\/+$/, ""),
475
+ command: `remnic oauth approve ${txn.ref}`
476
+ })
477
+ );
478
+ }
479
+ async challengeForAuthorizationCode(client, authorizationCode) {
480
+ const entry = this.state.peekCode(authorizationCode);
481
+ if (!entry || !timingSafeStringEqual(entry.clientId, client.client_id)) {
482
+ throw new InvalidGrantError("authorization code is invalid, expired, or already used");
483
+ }
484
+ return entry.codeChallenge;
485
+ }
486
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
487
+ const entry = this.state.takeCode({
488
+ code: authorizationCode,
489
+ clientId: client.client_id,
490
+ redirectUri,
491
+ resource: resource?.href
492
+ });
493
+ if (!entry) {
494
+ throw new InvalidGrantError("authorization code is invalid, expired, or already used");
495
+ }
496
+ const tokenEntry = buildTokenEntry(CHATGPT_CONNECTOR_ID);
497
+ try {
498
+ commitTokenEntry(tokenEntry, this.tokensPath);
499
+ } catch (err) {
500
+ this.state.reviveCode(authorizationCode);
501
+ log.warn(
502
+ `OAuth token persist failed; authorization code revived for retry: ${err instanceof Error ? err.message : String(err)}`
503
+ );
504
+ throw new ServerError("failed to persist the issued token; retry the exchange");
505
+ }
506
+ log.info(`OAuth token issued for connector "${CHATGPT_CONNECTOR_ID}" (client=${client.client_id})`);
507
+ return {
508
+ access_token: tokenEntry.token,
509
+ token_type: "Bearer"
510
+ };
511
+ }
512
+ async exchangeRefreshToken() {
513
+ throw new UnsupportedGrantTypeError(
514
+ "refresh_token grant is not supported; re-link the app to rotate the token"
515
+ );
516
+ }
517
+ async verifyAccessToken(token) {
518
+ const valid = getAllValidTokensCached(this.tokensPath).some(
519
+ (candidate) => timingSafeStringEqual(candidate, token)
520
+ );
521
+ if (!valid) {
522
+ throw new InvalidTokenError("unknown or revoked token");
523
+ }
524
+ return {
525
+ token,
526
+ clientId: this.config.clientId,
527
+ scopes: []
528
+ };
529
+ }
530
+ };
531
+ function buildOAuthApp(config, authCtxLookup, deps) {
532
+ const state = new OAuthState(config);
533
+ const provider = new RemnicOAuthProvider(config, state, deps?.tokensPath);
534
+ const issuer = new URL(config.issuerUrl);
535
+ const resourceServerUrl = new URL("/mcp", issuer);
536
+ if (config.redirectUris.length === 0) {
537
+ log.warn(
538
+ "server.oauth.redirectUris is empty \u2014 OAuth discovery is live but every authorization will be refused until the exact callback URL from ChatGPT's app management page is added (setup mode)."
539
+ );
540
+ }
541
+ const app = express();
542
+ app.disable("x-powered-by");
543
+ const oauthMetadata = createOAuthMetadata({ provider, issuerUrl: issuer });
544
+ oauthMetadata.token_endpoint_auth_methods_supported = [config.tokenEndpointAuthMethod];
545
+ oauthMetadata.grant_types_supported = ["authorization_code"];
546
+ app.use("/authorize", authorizationHandler({ provider }));
547
+ app.use("/token", tokenHandler({ provider }));
548
+ app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl, resourceName: "Remnic" }));
549
+ app.get("/.well-known/oauth-protected-resource", (_req, res) => {
550
+ res.status(200).json({
551
+ resource: resourceServerUrl.href,
552
+ authorization_servers: [oauthMetadata.issuer],
553
+ resource_name: "Remnic"
554
+ });
555
+ });
556
+ app.use(express.json({ limit: "32kb" }));
557
+ const makeLimiter = (max) => rateLimit({
558
+ windowMs: 6e4,
559
+ max,
560
+ standardHeaders: true,
561
+ legacyHeaders: false,
562
+ message: { error: "rate_limited", error_description: "too many requests; retry later" }
563
+ });
564
+ const pollLimiter = makeLimiter(120);
565
+ const operatorReadLimiter = makeLimiter(60);
566
+ const operatorDecisionLimiter = makeLimiter(30);
567
+ app.post("/oauth/authorize/poll", pollLimiter, (req, res) => {
568
+ const body = req.body;
569
+ if (!isPlainObject(body) || typeof body.txn !== "string" || typeof body.pollSecret !== "string" || body.txn.length === 0 || body.pollSecret.length === 0) {
570
+ res.status(400).json({ error: "invalid_request", error_description: "txn and pollSecret are required" });
571
+ return;
572
+ }
573
+ const result = state.poll(body.txn, body.pollSecret);
574
+ if (result === void 0) {
575
+ res.status(401).json({ error: "invalid_request", error_description: "unknown txn or wrong pollSecret" });
576
+ return;
577
+ }
578
+ res.status(200).json(result);
579
+ });
580
+ function operatorGuard(req, res) {
581
+ if (authCtxLookup(req).authorized === true) return true;
582
+ res.status(401).json({ error: "unauthorized", error_description: "operator authentication required" });
583
+ return false;
584
+ }
585
+ app.get("/oauth/pending", operatorReadLimiter, (req, res) => {
586
+ if (!operatorGuard(req, res)) return;
587
+ const pending = state.listPending().map((txn) => ({
588
+ ref: txn.ref,
589
+ clientId: txn.clientId,
590
+ redirectUri: txn.redirectUri,
591
+ scopes: txn.scopes,
592
+ resource: txn.resource ?? null,
593
+ createdAt: new Date(txn.createdAt).toISOString(),
594
+ expiresAt: new Date(txn.expiresAt).toISOString()
595
+ }));
596
+ res.status(200).json({ pending });
597
+ });
598
+ function decisionHandler(action) {
599
+ return (req, res) => {
600
+ if (!operatorGuard(req, res)) return;
601
+ const ref = req.params.ref;
602
+ if (!ref) {
603
+ res.status(400).json({ error: "invalid_request", error_description: "ref is required" });
604
+ return;
605
+ }
606
+ try {
607
+ if (action === "approve") {
608
+ const { txn, code } = state.approveByRef(ref);
609
+ res.status(200).json({ ref, status: "approved", redirect: state.buildRedirect(txn, code) });
610
+ } else {
611
+ state.denyByRef(ref);
612
+ res.status(200).json({ ref, status: "denied" });
613
+ }
614
+ } catch (err) {
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ if (message === "unknown or expired ref") {
617
+ res.status(404).json({ error: "invalid_request", error_description: message });
618
+ return;
619
+ }
620
+ if (message === "denied" || message === "already approved") {
621
+ res.status(409).json({ error: "invalid_request", error_description: message });
622
+ return;
623
+ }
624
+ log.warn(`OAuth ${action} endpoint unexpected error: ${message}`);
625
+ res.status(500).json({ error: "server_error", error_description: "internal error" });
626
+ }
627
+ };
628
+ }
629
+ app.post("/oauth/pending/:ref/approve", operatorDecisionLimiter, decisionHandler("approve"));
630
+ app.post("/oauth/pending/:ref/deny", operatorDecisionLimiter, decisionHandler("deny"));
631
+ return {
632
+ app,
633
+ state,
634
+ ownedExactPaths: [
635
+ "/authorize",
636
+ "/token",
637
+ "/.well-known/oauth-authorization-server",
638
+ "/.well-known/oauth-protected-resource",
639
+ "/.well-known/oauth-protected-resource/mcp"
640
+ ],
641
+ ownedPathPrefixes: ["/oauth/"]
642
+ };
643
+ }
644
+ function buildOAuthRequestHandler(config, deps) {
645
+ if (!config.enabled) {
646
+ return async () => false;
647
+ }
648
+ const ctxByRequest = /* @__PURE__ */ new WeakMap();
649
+ const bundle = buildOAuthApp(config, (req) => ctxByRequest.get(req) ?? { authorized: false }, deps);
650
+ const exactPaths = new Set(bundle.ownedExactPaths);
651
+ return (req, res, ctx) => {
652
+ let pathname;
653
+ try {
654
+ pathname = new URL(req.url ?? "/", "http://placeholder").pathname;
655
+ } catch {
656
+ return Promise.resolve(false);
657
+ }
658
+ const owned = exactPaths.has(pathname) || bundle.ownedPathPrefixes.some((prefix) => pathname.startsWith(prefix));
659
+ if (!owned) return Promise.resolve(false);
660
+ ctxByRequest.set(req, ctx);
661
+ const { promise, resolve } = Promise.withResolvers();
662
+ const finish = () => resolve(true);
663
+ res.on("finish", finish);
664
+ res.on("close", finish);
665
+ bundle.app(req, res, (err) => {
666
+ if (!res.headersSent) {
667
+ if (err) {
668
+ log.warn(`OAuth facade error: ${err instanceof Error ? err.message : String(err)}`);
669
+ res.statusCode = 500;
670
+ res.setHeader("content-type", "application/json; charset=utf-8");
671
+ res.end(JSON.stringify({ error: "server_error", error_description: "internal error" }));
672
+ } else {
673
+ res.statusCode = 404;
674
+ res.setHeader("content-type", "application/json; charset=utf-8");
675
+ res.end(JSON.stringify({ error: "not_found", error_description: "unknown OAuth endpoint" }));
676
+ }
677
+ }
678
+ resolve(true);
679
+ });
680
+ return promise;
681
+ };
682
+ }
683
+
684
+ // src/index.ts
7
685
  function readCompatEnv(primary, legacy) {
8
686
  return process.env[primary] ?? process.env[legacy];
9
687
  }
@@ -569,9 +1247,9 @@ async function runStartupSearchWarmup(options) {
569
1247
  async function completeStartupReadiness(options) {
570
1248
  const timeoutMs = options.timeoutMs ?? STARTUP_WARMUP_TIMEOUT_MS;
571
1249
  const retryIntervalMs = options.retryIntervalMs ?? STARTUP_WARMUP_RETRY_INTERVAL_MS;
572
- const warn = options.warn ?? ((message) => log.warn(message));
573
- const info = options.info ?? ((message) => log.info(message));
574
- const error = options.error ?? ((message) => log.error(message));
1250
+ const warn = options.warn ?? ((message) => log2.warn(message));
1251
+ const info = options.info ?? ((message) => log2.info(message));
1252
+ const error = options.error ?? ((message) => log2.error(message));
575
1253
  options.state.ready = false;
576
1254
  options.state.lastError = null;
577
1255
  if (options.override) {
@@ -672,12 +1350,12 @@ async function cleanupFailedStartup(orchestrator, httpServer) {
672
1350
  try {
673
1351
  await httpServer.stop();
674
1352
  } catch (err) {
675
- log.warn(`HTTP startup failure cleanup could not stop server: ${err}`);
1353
+ log2.warn(`HTTP startup failure cleanup could not stop server: ${err}`);
676
1354
  }
677
1355
  try {
678
1356
  await orchestrator.destroy();
679
1357
  } catch (err) {
680
- log.warn(`HTTP startup failure cleanup could not destroy orchestrator: ${err}`);
1358
+ log2.warn(`HTTP startup failure cleanup could not destroy orchestrator: ${err}`);
681
1359
  }
682
1360
  }
683
1361
  async function startServer(options) {
@@ -705,14 +1383,22 @@ async function startServer(options) {
705
1383
  const readiness = { ready: false, warmupAttempts: 0, lastError: null };
706
1384
  const authToken = parsedServerConfig.authToken ?? readCompatEnv("REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN") ?? "";
707
1385
  if (!authToken && getAllValidTokens().length === 0) {
708
- log.warn("No auth token set \u2014 server will reject all requests. Set REMNIC_AUTH_TOKEN, server.authToken in config, or generate tokens with 'remnic token generate'.");
1386
+ log2.warn("No auth token set \u2014 server will reject all requests. Set REMNIC_AUTH_TOKEN, server.authToken in config, or generate tokens with 'remnic token generate'.");
709
1387
  }
1388
+ const oauthConfig = applyOAuthEnvOverrides(serverConfig.oauth);
1389
+ const oauthRequestHandler = buildOAuthRequestHandler(oauthConfig);
710
1390
  const httpServer = new EngramAccessHttpServer({
711
1391
  service,
712
1392
  host: parsedServerConfig.host,
713
1393
  port: parsedServerConfig.port,
714
1394
  authToken: authToken || void 0,
715
- authTokensGetter: () => getAllValidTokensCached(),
1395
+ // Entry-based getter: validation + connector identity from ONE cached
1396
+ // snapshot (see tokens.ts). The path policy pins ChatGPT-minted OAuth
1397
+ // tokens (connector "chatgpt") to the MCP endpoint only — they never
1398
+ // authorize REST/admin routes. All other connector tokens keep full
1399
+ // access, matching pre-OAuth behavior.
1400
+ authTokenEntriesGetter: () => getAllValidTokenEntriesCached(),
1401
+ tokenPathPolicy: (connector, pathname) => connector !== "chatgpt" || pathname === "/mcp",
716
1402
  readiness: () => readiness,
717
1403
  principal: parsedServerConfig.principal,
718
1404
  maxBodyBytes: parsedServerConfig.maxBodyBytes,
@@ -722,7 +1408,14 @@ async function startServer(options) {
722
1408
  adminControls: parsedServerConfig.adminConsoleEnabled ? createAdminControls(resolvedConfigPath.path, config, parsedServerConfig) : void 0,
723
1409
  citationsEnabled: config.citationsEnabled,
724
1410
  citationsAutoDetect: config.citationsAutoDetect,
725
- emitLegacyTools: config.emitLegacyTools
1411
+ emitLegacyTools: config.emitLegacyTools,
1412
+ ...oauthConfig.enabled ? {
1413
+ externalRequestHandler: oauthRequestHandler,
1414
+ resourceMetadataUrl: new URL(
1415
+ "/.well-known/oauth-protected-resource/mcp",
1416
+ oauthConfig.issuerUrl
1417
+ ).href
1418
+ } : {}
726
1419
  });
727
1420
  let host;
728
1421
  let port;
@@ -797,47 +1490,47 @@ async function startServer(options) {
797
1490
  httpServer.stop = stop;
798
1491
  orchestrator.deferredReady.then(() => {
799
1492
  if (startupSyncAbort.signal.aborted) {
800
- log.debug("QMD startup-sync: cancelled before deferred init completed");
1493
+ log2.debug("QMD startup-sync: cancelled before deferred init completed");
801
1494
  return;
802
1495
  }
803
1496
  if (!config.qmdEnabled || orchestrator.qmd.debugStatus() === "backend=noop") {
804
- log.debug("QMD startup-sync: search disabled or noop backend, skipping retries");
1497
+ log2.debug("QMD startup-sync: search disabled or noop backend, skipping retries");
805
1498
  return;
806
1499
  }
807
1500
  const needsRetry = !orchestrator.qmd.isAvailable() || !orchestrator.deferredSyncSucceeded;
808
1501
  if (!needsRetry) {
809
- log.debug("QMD startup-sync: deferred init completed successfully, no retries needed");
1502
+ log2.debug("QMD startup-sync: deferred init completed successfully, no retries needed");
810
1503
  return;
811
1504
  }
812
1505
  const RETRY_DELAYS_MS = [5e3, 15e3, 3e4, 6e4, 12e4];
813
1506
  if (startupSyncAbort.signal.aborted) {
814
- log.debug("QMD startup-sync retry: cancelled before retry task started");
1507
+ log2.debug("QMD startup-sync retry: cancelled before retry task started");
815
1508
  return;
816
1509
  }
817
1510
  (async () => {
818
1511
  for (const delay of RETRY_DELAYS_MS) {
819
1512
  await abortableDelay(delay, startupSyncAbort.signal);
820
1513
  if (startupSyncAbort.signal.aborted) {
821
- log.debug("QMD startup-sync retry: cancelled by shutdown");
1514
+ log2.debug("QMD startup-sync retry: cancelled by shutdown");
822
1515
  return;
823
1516
  }
824
1517
  const synced = await ensureStartupSync(startupSyncAbort.signal);
825
1518
  if (!synced) {
826
1519
  if (orchestrator.qmd.debugStatus() === "backend=noop") {
827
- log.debug("QMD startup-sync retry: search intentionally disabled; stopping retries");
1520
+ log2.debug("QMD startup-sync retry: search intentionally disabled; stopping retries");
828
1521
  return;
829
1522
  }
830
- log.debug(`QMD startup-sync retry: not available yet (next retry in ${RETRY_DELAYS_MS[RETRY_DELAYS_MS.indexOf(delay) + 1] ?? "n/a"}ms)`);
1523
+ log2.debug(`QMD startup-sync retry: not available yet (next retry in ${RETRY_DELAYS_MS[RETRY_DELAYS_MS.indexOf(delay) + 1] ?? "n/a"}ms)`);
831
1524
  continue;
832
1525
  }
833
1526
  return;
834
1527
  }
835
- log.warn("QMD startup-sync retry: exhausted all retries; search index may be stale");
1528
+ log2.warn("QMD startup-sync retry: exhausted all retries; search index may be stale");
836
1529
  })().catch((err) => {
837
- log.warn(`QMD startup-sync retry: unexpected error: ${err}`);
1530
+ log2.warn(`QMD startup-sync retry: unexpected error: ${err}`);
838
1531
  });
839
1532
  }).catch((err) => {
840
- log.warn(`Deferred init error: ${err}`);
1533
+ log2.warn(`Deferred init error: ${err}`);
841
1534
  });
842
1535
  return { config, service, httpServer, host, port, stop, cancelStartupSync: () => startupSyncAbort.abort(), abortDeferredInit: () => orchestrator.abortDeferredInit() };
843
1536
  }