@remnic/server 9.3.768 → 9.3.770
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/README.md +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +712 -19
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
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,
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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…</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) =>
|
|
573
|
-
const info = options.info ?? ((message) =>
|
|
574
|
-
const error = options.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1520
|
+
log2.debug("QMD startup-sync retry: search intentionally disabled; stopping retries");
|
|
828
1521
|
return;
|
|
829
1522
|
}
|
|
830
|
-
|
|
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
|
-
|
|
1528
|
+
log2.warn("QMD startup-sync retry: exhausted all retries; search index may be stale");
|
|
836
1529
|
})().catch((err) => {
|
|
837
|
-
|
|
1530
|
+
log2.warn(`QMD startup-sync retry: unexpected error: ${err}`);
|
|
838
1531
|
});
|
|
839
1532
|
}).catch((err) => {
|
|
840
|
-
|
|
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
|
}
|