machine-bridge-mcp 0.2.1 → 0.2.4
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 +27 -33
- package/package.json +1 -1
- package/src/local/api-server.mjs +199 -91
- package/src/local/cli.mjs +40 -27
- package/src/local/self-test.mjs +164 -20
- package/src/local/state.mjs +8 -0
- package/src/worker/index.ts +259 -16
package/src/worker/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
|
|
3
3
|
const SERVER_NAME = "machine-bridge-mcp";
|
|
4
|
-
const SERVER_VERSION = "0.2.
|
|
4
|
+
const SERVER_VERSION = "0.2.4";
|
|
5
5
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
6
6
|
const JSONRPC_VERSION = "2.0";
|
|
7
7
|
const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
@@ -50,6 +50,12 @@ interface OAuthToken {
|
|
|
50
50
|
expires_at: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
interface AuthenticatedClient {
|
|
54
|
+
clientId: string;
|
|
55
|
+
scope: string;
|
|
56
|
+
resource: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
interface OAuthStore {
|
|
54
60
|
clients: Record<string, OAuthClient>;
|
|
55
61
|
codes: Record<string, OAuthCode>;
|
|
@@ -70,6 +76,22 @@ interface PendingCall {
|
|
|
70
76
|
resolve: (value: unknown) => void;
|
|
71
77
|
reject: (error: Error) => void;
|
|
72
78
|
timeout: ReturnType<typeof setTimeout>;
|
|
79
|
+
streamId?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface McpClientState {
|
|
83
|
+
clientId: string;
|
|
84
|
+
initializedAt: string;
|
|
85
|
+
capabilities: Record<string, unknown>;
|
|
86
|
+
clientInfo?: Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface McpClientStream {
|
|
90
|
+
id: string;
|
|
91
|
+
clientId: string;
|
|
92
|
+
connectedAt: number;
|
|
93
|
+
writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
94
|
+
heartbeat: ReturnType<typeof setInterval>;
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
const serverInfoTool = {
|
|
@@ -195,6 +217,9 @@ const MCP_INSTRUCTIONS = [
|
|
|
195
217
|
|
|
196
218
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
197
219
|
private readonly pending = new Map<string, PendingCall>();
|
|
220
|
+
private readonly pendingClientRequests = new Map<string, PendingCall>();
|
|
221
|
+
private readonly mcpClients = new Map<string, McpClientState>();
|
|
222
|
+
private readonly mcpClientStreams = new Map<string, McpClientStream>();
|
|
198
223
|
|
|
199
224
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
200
225
|
super(ctx, env);
|
|
@@ -209,10 +234,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
209
234
|
}
|
|
210
235
|
|
|
211
236
|
if (url.pathname === "/" && request.method === "GET") {
|
|
212
|
-
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false) });
|
|
237
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, mcp: `${base}/mcp`, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
|
|
213
238
|
}
|
|
214
239
|
if (url.pathname === "/healthz") {
|
|
215
|
-
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false) });
|
|
240
|
+
return json({ ok: true, server: SERVER_NAME, version: SERVER_VERSION, daemon: this.daemonStatus(false), mcp_clients: this.mcpClientStatus(false) });
|
|
216
241
|
}
|
|
217
242
|
if (url.pathname === "/.well-known/mcp.json") {
|
|
218
243
|
return json(this.mcpMetadata(base));
|
|
@@ -235,6 +260,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
235
260
|
if (url.pathname === "/daemon/ws") return this.acceptDaemonWebSocket(request);
|
|
236
261
|
if (url.pathname === "/mcp") return this.handleMcp(request, base);
|
|
237
262
|
if (url.pathname === "/api/daemon/status") return json(this.daemonStatus(false));
|
|
263
|
+
if (url.pathname === "/api/mcp/sampling") return this.handleSamplingApi(request);
|
|
238
264
|
return json({ error: "not_found" }, 404);
|
|
239
265
|
} catch (error) {
|
|
240
266
|
if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
|
|
@@ -297,27 +323,33 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
297
323
|
}
|
|
298
324
|
|
|
299
325
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
300
|
-
if (request.method === "GET") return new Response("GET SSE stream is not implemented; use POST Streamable HTTP.", { status: 405 });
|
|
301
326
|
if (request.method === "DELETE") return new Response(null, { status: 405 });
|
|
302
|
-
if (request.method !== "POST") return json({ error: "mcp endpoint expects POST JSON-RPC" }, 405);
|
|
327
|
+
if (request.method !== "POST" && request.method !== "GET") return json({ error: "mcp endpoint expects POST JSON-RPC or GET SSE" }, 405);
|
|
303
328
|
|
|
304
|
-
|
|
329
|
+
const auth = await this.verifyAccessToken(bearerToken(request), base);
|
|
330
|
+
if (!auth) {
|
|
305
331
|
return new Response("OAuth bearer token required", {
|
|
306
332
|
status: 401,
|
|
307
333
|
headers: { "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"` },
|
|
308
334
|
});
|
|
309
335
|
}
|
|
310
336
|
|
|
337
|
+
if (request.method === "GET") return this.openMcpSseStream(request, auth);
|
|
338
|
+
|
|
311
339
|
const body = await parseJsonRequest(request, this.bodyLimitBytes());
|
|
312
|
-
if (isJsonRpcResponse(body))
|
|
340
|
+
if (isJsonRpcResponse(body)) {
|
|
341
|
+
this.handleClientJsonRpcResponse(body);
|
|
342
|
+
return new Response(null, { status: 202 });
|
|
343
|
+
}
|
|
313
344
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
314
|
-
const response = await this.dispatchJsonRpc(body, base);
|
|
345
|
+
const response = await this.dispatchJsonRpc(body, base, auth);
|
|
315
346
|
if (response === null) return new Response(null, { status: 202 });
|
|
316
347
|
return json(response);
|
|
317
348
|
}
|
|
318
349
|
|
|
319
|
-
private async dispatchJsonRpc(request: JsonRpcRequest, base: string): Promise<Record<string, unknown> | null> {
|
|
350
|
+
private async dispatchJsonRpc(request: JsonRpcRequest, base: string, auth: AuthenticatedClient): Promise<Record<string, unknown> | null> {
|
|
320
351
|
if (request.method === "initialize") {
|
|
352
|
+
this.recordClientInitialize(auth, request.params);
|
|
321
353
|
return rpcResult(request.id, {
|
|
322
354
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
323
355
|
capabilities: { tools: { listChanged: false } },
|
|
@@ -350,6 +382,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
350
382
|
mcp_url: `${base}/mcp`,
|
|
351
383
|
oauth: this.authorizationServerMetadata(base),
|
|
352
384
|
daemon: this.daemonStatus(true),
|
|
385
|
+
mcp_clients: this.mcpClientStatus(true),
|
|
353
386
|
tools: this.allTools().map((tool) => tool.name),
|
|
354
387
|
};
|
|
355
388
|
}
|
|
@@ -401,6 +434,172 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
401
434
|
return new Response(null, { status: 101, webSocket: client });
|
|
402
435
|
}
|
|
403
436
|
|
|
437
|
+
private async handleSamplingApi(request: Request): Promise<Response> {
|
|
438
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed", message: "POST required" }, 405);
|
|
439
|
+
const expected = this.env.DAEMON_SHARED_SECRET ?? "";
|
|
440
|
+
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
441
|
+
if (!expected || !(await safeEqual(supplied, expected))) return json({ error: "unauthorized", message: "Unauthorized local API bridge request" }, 401);
|
|
442
|
+
|
|
443
|
+
const body = await parseRequestBody(request, this.bodyLimitBytes());
|
|
444
|
+
const timeoutMs = clampNumber(body.timeout_ms ?? body.timeoutMs, 180_000, 1_000, 600_000);
|
|
445
|
+
const params = samplingParamsFromApiBody(body);
|
|
446
|
+
const result = await this.requestClientSampling(params, timeoutMs);
|
|
447
|
+
return json({ ok: true, result });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private openMcpSseStream(request: Request, auth: AuthenticatedClient): Response {
|
|
451
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
|
|
452
|
+
const writer = writable.getWriter();
|
|
453
|
+
const streamId = randomToken("mcp_stream");
|
|
454
|
+
const stream: McpClientStream = {
|
|
455
|
+
id: streamId,
|
|
456
|
+
clientId: auth.clientId,
|
|
457
|
+
connectedAt: Date.now(),
|
|
458
|
+
writer,
|
|
459
|
+
heartbeat: setInterval(() => {
|
|
460
|
+
void writeSseComment(writer, `keepalive ${Date.now()}`).catch(() => this.closeMcpClientStream(streamId));
|
|
461
|
+
}, 25_000),
|
|
462
|
+
};
|
|
463
|
+
this.mcpClientStreams.set(streamId, stream);
|
|
464
|
+
request.signal.addEventListener("abort", () => this.closeMcpClientStream(streamId), { once: true });
|
|
465
|
+
void writeSseComment(writer, `${SERVER_NAME} connected`).catch(() => this.closeMcpClientStream(streamId));
|
|
466
|
+
return new Response(readable, {
|
|
467
|
+
status: 200,
|
|
468
|
+
headers: {
|
|
469
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
470
|
+
"cache-control": "no-cache, no-transform",
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private closeMcpClientStream(streamId: string): void {
|
|
476
|
+
const stream = this.mcpClientStreams.get(streamId);
|
|
477
|
+
if (!stream) return;
|
|
478
|
+
this.mcpClientStreams.delete(streamId);
|
|
479
|
+
clearInterval(stream.heartbeat);
|
|
480
|
+
for (const [id, pending] of this.pendingClientRequests) {
|
|
481
|
+
if (pending.streamId !== streamId) continue;
|
|
482
|
+
clearTimeout(pending.timeout);
|
|
483
|
+
this.pendingClientRequests.delete(id);
|
|
484
|
+
pending.reject(new HttpError(
|
|
485
|
+
409,
|
|
486
|
+
"mcp_client_stream_closed",
|
|
487
|
+
"The MCP client server-to-client stream closed before it answered sampling/createMessage. Reconnect ChatGPT to the MCP Server URL and retry."
|
|
488
|
+
));
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
void stream.writer.close();
|
|
492
|
+
} catch {
|
|
493
|
+
// Ignore already-closed streams.
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async requestClientSampling(params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
|
|
498
|
+
const streams = [...this.mcpClientStreams.values()].sort((left, right) => right.connectedAt - left.connectedAt);
|
|
499
|
+
if (!streams.length) {
|
|
500
|
+
throw new HttpError(
|
|
501
|
+
409,
|
|
502
|
+
"mcp_client_stream_missing",
|
|
503
|
+
"No MCP client has an open server-to-client stream. Connect ChatGPT to the printed MCP Server URL and keep a client stream open so this bridge can send sampling/createMessage requests."
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const capableStreams = streams.filter((stream) => this.clientSupportsSampling(stream.clientId));
|
|
508
|
+
if (!capableStreams.length) {
|
|
509
|
+
throw new HttpError(
|
|
510
|
+
501,
|
|
511
|
+
"mcp_sampling_not_supported",
|
|
512
|
+
"A ChatGPT MCP client stream is connected, but the client did not advertise the MCP sampling capability. This local /v1 API requires a client that can receive sampling/createMessage requests."
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const request: JsonRpcRequest = {
|
|
517
|
+
jsonrpc: JSONRPC_VERSION,
|
|
518
|
+
id: randomToken("sampling"),
|
|
519
|
+
method: "sampling/createMessage",
|
|
520
|
+
params,
|
|
521
|
+
};
|
|
522
|
+
return this.sendClientRequest(capableStreams[0], request, timeoutMs);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async sendClientRequest(stream: McpClientStream, request: JsonRpcRequest, timeoutMs: number): Promise<unknown> {
|
|
526
|
+
const id = String(request.id);
|
|
527
|
+
return await new Promise((resolve, reject) => {
|
|
528
|
+
const timeout = setTimeout(() => {
|
|
529
|
+
this.pendingClientRequests.delete(id);
|
|
530
|
+
reject(new HttpError(
|
|
531
|
+
504,
|
|
532
|
+
"mcp_sampling_timeout",
|
|
533
|
+
"Timed out waiting for the MCP client to answer sampling/createMessage. Check that ChatGPT is still connected and that the sampling request was approved."
|
|
534
|
+
));
|
|
535
|
+
}, timeoutMs);
|
|
536
|
+
this.pendingClientRequests.set(id, { resolve, reject, timeout, streamId: stream.id });
|
|
537
|
+
void writeSseJson(stream.writer, request, id).catch((error) => {
|
|
538
|
+
clearTimeout(timeout);
|
|
539
|
+
this.pendingClientRequests.delete(id);
|
|
540
|
+
this.closeMcpClientStream(stream.id);
|
|
541
|
+
reject(new HttpError(409, "mcp_client_stream_unavailable", `MCP client stream is not writable: ${errorMessage(error)}`));
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private handleClientJsonRpcResponse(response: unknown): void {
|
|
547
|
+
const candidate = response as Record<string, unknown>;
|
|
548
|
+
const id = String(candidate.id ?? "");
|
|
549
|
+
if (!id) return;
|
|
550
|
+
const pending = this.pendingClientRequests.get(id);
|
|
551
|
+
if (!pending) return;
|
|
552
|
+
clearTimeout(pending.timeout);
|
|
553
|
+
this.pendingClientRequests.delete(id);
|
|
554
|
+
if ("error" in candidate) {
|
|
555
|
+
pending.reject(new HttpError(
|
|
556
|
+
502,
|
|
557
|
+
"mcp_sampling_client_error",
|
|
558
|
+
`MCP client returned an error for sampling/createMessage: ${jsonRpcErrorMessage(candidate.error)}`
|
|
559
|
+
));
|
|
560
|
+
}
|
|
561
|
+
else pending.resolve(candidate.result);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private recordClientInitialize(auth: AuthenticatedClient, params: unknown): void {
|
|
565
|
+
const body = asObject(params);
|
|
566
|
+
const meta = asObject(body._meta);
|
|
567
|
+
const directCapabilities = asObject(body.capabilities);
|
|
568
|
+
const metaCapabilities = asObject(meta["io.modelcontextprotocol/clientCapabilities"]);
|
|
569
|
+
const capabilities = Object.keys(directCapabilities).length ? directCapabilities : metaCapabilities;
|
|
570
|
+
this.mcpClients.set(auth.clientId, {
|
|
571
|
+
clientId: auth.clientId,
|
|
572
|
+
initializedAt: new Date().toISOString(),
|
|
573
|
+
capabilities,
|
|
574
|
+
clientInfo: asObject(body.clientInfo),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private clientSupportsSampling(clientId: string): boolean {
|
|
579
|
+
const capabilities = this.mcpClients.get(clientId)?.capabilities;
|
|
580
|
+
return Boolean(capabilities && Object.prototype.hasOwnProperty.call(capabilities, "sampling"));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private mcpClientStatus(detail: boolean): Record<string, unknown> {
|
|
584
|
+
const streams = [...this.mcpClientStreams.values()];
|
|
585
|
+
const samplingCapableClientIds = new Set([...this.mcpClients.values()].filter((client) => Object.prototype.hasOwnProperty.call(client.capabilities, "sampling")).map((client) => client.clientId));
|
|
586
|
+
const base = {
|
|
587
|
+
stream_count: streams.length,
|
|
588
|
+
initialized_count: this.mcpClients.size,
|
|
589
|
+
sampling_capable_count: samplingCapableClientIds.size,
|
|
590
|
+
};
|
|
591
|
+
if (!detail) return base;
|
|
592
|
+
return {
|
|
593
|
+
...base,
|
|
594
|
+
streams: streams.map((stream) => ({
|
|
595
|
+
id: stream.id,
|
|
596
|
+
client_id: stream.clientId,
|
|
597
|
+
connected_at: new Date(stream.connectedAt).toISOString(),
|
|
598
|
+
sampling_capable: samplingCapableClientIds.has(stream.clientId),
|
|
599
|
+
})),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
404
603
|
private allTools(): Array<Record<string, unknown>> {
|
|
405
604
|
const advertised = this.daemonAdvertisedTools();
|
|
406
605
|
const localTools = advertised
|
|
@@ -652,20 +851,21 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
652
851
|
return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
|
|
653
852
|
}
|
|
654
853
|
|
|
655
|
-
private async verifyAccessToken(token: string, base: string): Promise<
|
|
656
|
-
if (!token) return
|
|
854
|
+
private async verifyAccessToken(token: string, base: string): Promise<AuthenticatedClient | null> {
|
|
855
|
+
if (!token) return null;
|
|
657
856
|
const store = await this.oauthStore();
|
|
658
857
|
const key = `sha256:${await sha256Hex(token)}`;
|
|
659
858
|
const record = store.tokens[key];
|
|
660
|
-
if (!record) return
|
|
859
|
+
if (!record) return null;
|
|
661
860
|
if (record.expires_at <= Math.floor(Date.now() / 1000)) {
|
|
662
861
|
delete store.tokens[key];
|
|
663
862
|
await this.ctx.storage.put("oauth", store);
|
|
664
|
-
return
|
|
863
|
+
return null;
|
|
665
864
|
}
|
|
666
865
|
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
667
|
-
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return
|
|
668
|
-
|
|
866
|
+
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
867
|
+
if (record.resource !== `${base}/mcp`) return null;
|
|
868
|
+
return { clientId: record.client_id, scope: record.scope, resource: record.resource };
|
|
669
869
|
}
|
|
670
870
|
|
|
671
871
|
private bodyLimitBytes(): number {
|
|
@@ -708,6 +908,45 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
|
|
|
708
908
|
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], isError };
|
|
709
909
|
}
|
|
710
910
|
|
|
911
|
+
function samplingParamsFromApiBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
912
|
+
const messages = body.messages;
|
|
913
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
914
|
+
throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires a non-empty messages array");
|
|
915
|
+
}
|
|
916
|
+
const maxTokens = Number(body.maxTokens);
|
|
917
|
+
if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
|
|
918
|
+
throw new HttpError(400, "invalid_sampling_request", "sampling/createMessage requires maxTokens");
|
|
919
|
+
}
|
|
920
|
+
const params = { ...body };
|
|
921
|
+
delete params.timeout_ms;
|
|
922
|
+
delete params.timeoutMs;
|
|
923
|
+
return params;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function writeSseJson(writer: WritableStreamDefaultWriter<Uint8Array>, value: unknown, id?: string): Promise<void> {
|
|
927
|
+
const lines = [];
|
|
928
|
+
if (id) lines.push(`id: ${sseLine(id)}`);
|
|
929
|
+
lines.push("event: message");
|
|
930
|
+
for (const line of JSON.stringify(value).split(/\r?\n/)) lines.push(`data: ${line}`);
|
|
931
|
+
lines.push("", "");
|
|
932
|
+
await writer.write(new TextEncoder().encode(lines.join("\n")));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function writeSseComment(writer: WritableStreamDefaultWriter<Uint8Array>, value: string): Promise<void> {
|
|
936
|
+
await writer.write(new TextEncoder().encode(`: ${sseLine(value)}\n\n`));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function sseLine(value: string): string {
|
|
940
|
+
return value.replaceAll("\r", " ").replaceAll("\n", " ");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function jsonRpcErrorMessage(error: unknown): string {
|
|
944
|
+
const value = asObject(error);
|
|
945
|
+
const message = typeof value.message === "string" && value.message ? value.message : "MCP client returned an error";
|
|
946
|
+
const code = value.code === undefined ? "" : ` (${String(value.code)})`;
|
|
947
|
+
return `${message}${code}`;
|
|
948
|
+
}
|
|
949
|
+
|
|
711
950
|
function json(value: unknown, status = 200): Response {
|
|
712
951
|
return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json; charset=utf-8" } });
|
|
713
952
|
}
|
|
@@ -861,8 +1100,12 @@ function isAllowedRedirectUri(value: string): boolean {
|
|
|
861
1100
|
function validateOrigin(request: Request, base: string, configured = ""): boolean {
|
|
862
1101
|
const origin = request.headers.get("Origin");
|
|
863
1102
|
if (!origin) return true;
|
|
1103
|
+
if (isDefaultAllowedOrigin(origin, base)) return true;
|
|
864
1104
|
const allowed = configured.split(",").map((item) => item.trim()).filter(Boolean);
|
|
865
|
-
|
|
1105
|
+
return allowed.includes(origin);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function isDefaultAllowedOrigin(origin: string, base: string): boolean {
|
|
866
1109
|
try {
|
|
867
1110
|
const parsed = new URL(origin);
|
|
868
1111
|
if (origin === base) return true;
|