summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Peter Christen <mc@yacy.net>2026-07-04 13:01:38 +0200
committerMichael Peter Christen <mc@yacy.net>2026-07-04 13:01:38 +0200
commitadb04ae3971ee8f976cc946c943e757a625cb558 (patch)
tree6460a651ba111773ceb96efecaee8e8a6bc7c52b
parenta8b90eb3653cea8322a9e0101eda6c14c3f25cc8 (diff)
added llm proxy servlet to augment the RAGProxyServlet with admin
access. We need this to do remote LLM inference configuration.
-rw-r--r--defaults/web.xml14
-rw-r--r--htroot/LLMSelection_p.html36
-rw-r--r--source/net/yacy/http/servlets/LLMAdminProxyServlet.java272
-rw-r--r--source/net/yacy/http/servlets/OllamaTagsServlet.java4
-rw-r--r--source/net/yacy/http/servlets/OpenAIModelsServlet.java4
-rw-r--r--source/net/yacy/http/servlets/RAGProxyServlet.java11
6 files changed, 334 insertions, 7 deletions
diff --git a/defaults/web.xml b/defaults/web.xml
index 0e5241e6c..2e2efa894 100644
--- a/defaults/web.xml
+++ b/defaults/web.xml
@@ -65,6 +65,13 @@
<servlet-class>net.yacy.http.servlets.OpenAIModelsServlet</servlet-class>
</servlet>
+ <!-- admin passthrough proxy for configured LLM endpoints; also embedded in the
+ RAGProxy/OllamaTags/OpenAIModels servlets, activated by a hoststub parameter -->
+ <servlet>
+ <servlet-name>LLMAdminProxyServlet</servlet-name>
+ <servlet-class>net.yacy.http.servlets.LLMAdminProxyServlet</servlet-class>
+ </servlet>
+
<servlet>
<servlet-name>MCPSearchServlet</servlet-name>
<servlet-class>net.yacy.http.servlets.MCPSearchServlet</servlet-class>
@@ -119,6 +126,13 @@
</servlet-mapping>
<servlet-mapping>
+ <servlet-name>LLMAdminProxyServlet</servlet-name>
+ <url-pattern>/api/pull</url-pattern>
+ <url-pattern>/api/delete</url-pattern>
+ <url-pattern>/api/show</url-pattern>
+ </servlet-mapping>
+
+ <servlet-mapping>
<servlet-name>MCPSearchServlet</servlet-name>
<url-pattern>/tools</url-pattern>
<url-pattern>/tools/initialize</url-pattern>
diff --git a/htroot/LLMSelection_p.html b/htroot/LLMSelection_p.html
index 628ab1211..c7939b64f 100644
--- a/htroot/LLMSelection_p.html
+++ b/htroot/LLMSelection_p.html
@@ -128,8 +128,22 @@
/***
*** API functions to access Ollama or OpenAI endpoints (list/load/delete models)
+ ***
+ *** All endpoint calls are routed through the YaCy-internal admin passthrough
+ *** proxy (see LLMAdminProxyServlet.java): instead of calling the LLM endpoint
+ *** directly, the browser calls the same API path on YaCy itself (same-origin)
+ *** and passes the target endpoint as hoststub parameter. This keeps endpoints
+ *** reachable which are only visible from the YaCy server (remote YaCy
+ *** installations), avoids CORS/mixed-content issues and lets YaCy inject the
+ *** stored api_key server-side. The proxy requires admin authentication, which
+ *** the browser already holds on this page.
***/
+ function proxyUrl(hoststub, path) {
+ const target = (hoststub || "").trim().replace(/\/+$/, "");
+ return `${path}?hoststub=${encodeURIComponent(target)}`;
+ }
+
async function fetchJsonOrThrow(url, options = {}) {
const response = await fetch(url, options);
if (response.status !== 200) {
@@ -222,7 +236,7 @@
}
async function deleteOllamaModel(hoststub, modelName) {
- const response = await fetch(`${hoststub}/api/delete`, {
+ const response = await fetch(proxyUrl(hoststub, "/api/delete"), {
method: "DELETE",
headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: JSON.stringify({ model: modelName })
@@ -235,7 +249,7 @@
}
async function downloadOllamaModel(hoststub, modelName) {
- const response = await fetch(`${hoststub}/api/pull`, {
+ const response = await fetch(proxyUrl(hoststub, "/api/pull"), {
method: "POST",
headers: {"Accept": "application/json", "Content-Type": "application/json"},
body: JSON.stringify({ model: modelName, stream: false })
@@ -256,7 +270,15 @@
}
async function requestModelsForService(service, hoststub) {
- return service === "OLLAMA" ? fetchJsonOrThrow(`${hoststub}/api/tags`) : fetchJsonOrThrow(`${hoststub}/v1/models`);
+ const url = service === "OLLAMA" ? proxyUrl(hoststub, "/api/tags") : proxyUrl(hoststub, "/v1/models");
+ const options = {};
+ const apikeyEl = document.getElementById("apikey");
+ const apikey = apikeyEl ? apikeyEl.value.trim() : "";
+ if (apikey) {
+ // for endpoints which are not saved yet the proxy cannot look up the key itself
+ options.headers = { "Authorization": `Bearer ${apikey}` };
+ }
+ return fetchJsonOrThrow(url, options);
}
function handleModelLoadError(service, error) {
@@ -1200,7 +1222,7 @@
if (!endpointBase) {
return false;
}
- const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath);
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
@@ -1296,7 +1318,7 @@
if (!endpointBase) {
return false;
}
- const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath);
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
@@ -1429,7 +1451,7 @@
if (!endpointBase) {
return false;
}
- const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath);
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
@@ -1618,7 +1640,7 @@
}
async function runSingleFormatCapabilityTest(service, endpointBase, modelName, apikey, inputText) {
- const targetUrl = `${endpointBase}${TEST_STRINGS.toolingEndpointPath}`;
+ const targetUrl = proxyUrl(endpointBase, TEST_STRINGS.toolingEndpointPath);
const headers = { "Content-Type": "application/json" };
if (apikey) {
headers.Authorization = `Bearer ${apikey}`;
diff --git a/source/net/yacy/http/servlets/LLMAdminProxyServlet.java b/source/net/yacy/http/servlets/LLMAdminProxyServlet.java
new file mode 100644
index 000000000..ff4ccd0d3
--- /dev/null
+++ b/source/net/yacy/http/servlets/LLMAdminProxyServlet.java
@@ -0,0 +1,272 @@
+/**
+ * LLMAdminProxyServlet
+ * Copyright 2026 by Michael Peter Christen
+ * First released 04.07.2026 at https://yacy.net
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program in the file lgpl21.txt
+ * If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package net.yacy.http.servlets;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+
+import net.yacy.cora.protocol.Domains;
+import net.yacy.cora.util.ConcurrentLog;
+import net.yacy.cora.util.LogRedaction;
+import net.yacy.data.UserDB;
+import net.yacy.search.Switchboard;
+import net.yacy.search.SwitchboardConstants;
+
+/**
+ * Admin passthrough proxy for OpenAI-API compatible LLM endpoints (Ollama, LM Studio,
+ * OpenAI, OpenRouter, ...). This makes the LLM endpoints which are configured in
+ * /LLMSelection_p.html reachable for the browser front-end even when YaCy runs on a
+ * remote host and the LLM endpoint is only visible from the YaCy server, not from the
+ * user's machine. As a side effect all endpoint calls become same-origin requests,
+ * which also avoids CORS and mixed-content (https YaCy / http Ollama) problems.
+ *
+ * Concept - one endpoint, two operation modes:
+ * YaCy mirrors the LLM API paths (/v1/chat/completions, /api/tags, /v1/models,
+ * /api/pull, /api/delete, /api/show) on its own port. The mode of a request is not
+ * distinguished by the path but by the presence of a "hoststub" request parameter
+ * (or X-LLM-Hoststub header) that selects the target endpoint:
+ *
+ * - Public mode (no hoststub): the request is NOT handled here. The serving servlet
+ * (RAGProxyServlet, OllamaTagsServlet, OpenAIModelsServlet) keeps its normal public
+ * behavior: target endpoint, model routing and api_key come exclusively from the
+ * server-side configuration, and the AIShield rules (localhost restriction, rate
+ * limiting, see /AIShield_p.html) apply. This mode serves yacychat.html and external
+ * OpenAI-compatible clients, also for non-admin users.
+ *
+ * - Admin passthrough mode (hoststub given): the request is forwarded 1:1 to the given
+ * endpoint, streaming the response back chunk by chunk. Because here the caller
+ * determines the proxy target, this mode is strictly limited to authenticated
+ * administrators; AIShield rules and RAG augmentation are skipped. This mode serves
+ * the endpoint management functions of /LLMSelection_p.html (model lists, model
+ * pull/delete, capability tests).
+ *
+ * Security invariant: "hoststub given => admin authentication enforced before anything
+ * else happens". A non-admin user cannot turn the public mode into an open proxy.
+ * Additional hardening even for admins: only the paths listed above are mirrored, and
+ * mutating operations are limited to endpoints already present in the configuration;
+ * arbitrary hoststubs are only accepted for the read-only model list probes. The
+ * api_key is injected server-side from the stored configuration, so it does not need
+ * to be exposed to the browser.
+ *
+ * This class is both a servlet on its own (for the paths which exist only in
+ * passthrough mode, mapped in defaults/web.xml) and a static helper embedded in the
+ * servlets that share their path with the public mode.
+ */
+public class LLMAdminProxyServlet extends HttpServlet {
+
+ private static final long serialVersionUID = 3411544789759643138L;
+
+ private static final String HOSTSTUB_PARAMETER = "hoststub";
+ private static final String HOSTSTUB_HEADER = "X-LLM-Hoststub";
+
+ /** paths that this proxy is willing to mirror on the target endpoint */
+ private static final Set<String> PROXY_PATHS = Set.of(
+ "/v1/chat/completions", "/v1/models",
+ "/api/tags", "/api/show", "/api/pull", "/api/delete");
+
+ /** read-only probe paths which may be used with a not-yet-saved hoststub */
+ private static final Set<String> PROBE_PATHS = Set.of("/api/tags", "/v1/models");
+
+ private static final HttpClient CLIENT = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ @Override
+ public void service(ServletRequest request, ServletResponse response) throws IOException, ServletException {
+ // this servlet is mapped to paths which only exist in passthrough mode (i.e. /api/pull);
+ // without a hoststub there is nothing we can serve
+ if (!tryHandle((HttpServletRequest) request, (HttpServletResponse) response)) {
+ ((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST, "hoststub parameter required");
+ }
+ }
+
+ /**
+ * Handle the request in admin passthrough mode if a hoststub is given.
+ * Servlets on shared paths (RAGProxyServlet, OllamaTagsServlet, OpenAIModelsServlet)
+ * call this first and continue with their normal public behavior when this returns false.
+ * @return true if the request carried a hoststub and was fully handled (including error responses)
+ */
+ public static boolean tryHandle(final HttpServletRequest hrequest, final HttpServletResponse hresponse) throws IOException {
+ String hoststub = hrequest.getParameter(HOSTSTUB_PARAMETER);
+ if (hoststub == null || hoststub.trim().isEmpty()) hoststub = hrequest.getHeader(HOSTSTUB_HEADER);
+ if (hoststub == null || hoststub.trim().isEmpty()) return false;
+ handle(hrequest, hresponse, hoststub.trim());
+ return true;
+ }
+
+ private static void handle(final HttpServletRequest hrequest, final HttpServletResponse hresponse, String hoststub) throws IOException {
+ final long start = System.currentTimeMillis();
+ final String method = hrequest.getMethod();
+
+ if ("OPTIONS".equals(method)) {
+ hresponse.setStatus(HttpServletResponse.SC_OK);
+ return;
+ }
+
+ // the caller determines the proxy target, therefore this mode is admin-only
+ if (!requireAdmin(hrequest, hresponse)) {
+ ConcurrentLog.warn("LLMAdminProxy", "event=passthrough phase=reject reason=not-admin ip=" + hrequest.getRemoteAddr());
+ return;
+ }
+
+ while (hoststub.endsWith("/")) hoststub = hoststub.substring(0, hoststub.length() - 1);
+ if (!hoststub.startsWith("http://") && !hoststub.startsWith("https://")) {
+ hresponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "hoststub must be a http(s) URL");
+ return;
+ }
+
+ final String path = hrequest.getRequestURI();
+ if (!PROXY_PATHS.contains(path)) {
+ hresponse.sendError(HttpServletResponse.SC_NOT_FOUND, "path not proxied");
+ return;
+ }
+
+ // resolve the api_key for the hoststub from the stored configuration;
+ // mutating operations are only allowed on configured endpoints
+ final Map<String, String> configured = configuredHoststubs();
+ final boolean known = configured.containsKey(hoststub);
+ if (!known && !PROBE_PATHS.contains(path)) {
+ hresponse.sendError(HttpServletResponse.SC_FORBIDDEN, "hoststub is not a configured endpoint");
+ return;
+ }
+
+ final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
+ .uri(URI.create(hoststub + path))
+ .timeout(Duration.ofMillis("/api/pull".equals(path) ? 60 * 60 * 1000 : 10 * 60 * 1000));
+ final String contentType = hrequest.getContentType();
+ if (contentType != null) requestBuilder.header("Content-Type", contentType);
+ final String accept = hrequest.getHeader("Accept");
+ if (accept != null) requestBuilder.header("Accept", accept);
+
+ // inject the Authorization header server-side; a client-provided header
+ // (needed to probe endpoints which are not saved yet) takes precedence
+ final String clientAuthorization = hrequest.getHeader("Authorization");
+ final String configuredKey = configured.get(hoststub);
+ if (clientAuthorization != null && !clientAuthorization.isEmpty()) {
+ requestBuilder.header("Authorization", clientAuthorization);
+ } else if (configuredKey != null && !configuredKey.isEmpty()) {
+ requestBuilder.header("Authorization", "Bearer " + configuredKey);
+ }
+
+ if ("GET".equals(method) || "HEAD".equals(method)) {
+ requestBuilder.method(method, HttpRequest.BodyPublishers.noBody());
+ } else {
+ final byte[] body = hrequest.getInputStream().readAllBytes();
+ requestBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(body));
+ }
+
+ ConcurrentLog.info("LLMAdminProxy", "event=passthrough phase=start method=" + method + " path=" + path + " backend=" + LogRedaction.redact(hoststub) + " known=" + known);
+ try {
+ final HttpResponse<InputStream> upstream = CLIENT.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
+ hresponse.setStatus(upstream.statusCode());
+ hresponse.setHeader("Content-Type", upstream.headers().firstValue("Content-Type").orElse("application/json;charset=utf-8"));
+ final ServletOutputStream out = hresponse.getOutputStream();
+ try (InputStream in = upstream.body()) {
+ final byte[] buffer = new byte[8192];
+ int count;
+ while ((count = in.read(buffer)) >= 0) {
+ out.write(buffer, 0, count);
+ out.flush(); // flush each chunk to support streamed responses (chat completions, pull progress)
+ }
+ }
+ ConcurrentLog.info("LLMAdminProxy", "event=passthrough phase=end status=" + upstream.statusCode() + " durationMs=" + (System.currentTimeMillis() - start));
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ hresponse.sendError(HttpServletResponse.SC_BAD_GATEWAY, "upstream request interrupted");
+ } catch (final IOException e) {
+ ConcurrentLog.warn("LLMAdminProxy", "event=passthrough phase=end result=failure reason=" + LogRedaction.redactMessage(e) + " durationMs=" + (System.currentTimeMillis() - start));
+ if (!hresponse.isCommitted()) hresponse.sendError(HttpServletResponse.SC_BAD_GATEWAY, "endpoint not reachable from the YaCy server");
+ }
+ }
+
+ /**
+ * Check for administrator access, mirroring the rules of YaCySecurityHandler:
+ * localhost access with the localhost-admin setting is granted without credentials,
+ * everything else requires an authenticated user with the admin right. Sends the
+ * authentication challenge (401) when credentials are missing.
+ */
+ private static boolean requireAdmin(final HttpServletRequest hrequest, final HttpServletResponse hresponse) throws IOException {
+ final Switchboard sb = Switchboard.getSwitchboard();
+ final String adminRole = UserDB.AccessRight.ADMIN_RIGHT.toString();
+ if (sb.getConfigBool(SwitchboardConstants.ADMIN_ACCOUNT_FOR_LOCALHOST, false)
+ && Domains.isLocalhost(hrequest.getRemoteAddr())) return true;
+ if (hrequest.isUserInRole(adminRole)) return true;
+ try {
+ if (hrequest.authenticate(hresponse) && hrequest.isUserInRole(adminRole)) return true;
+ } catch (final ServletException | IOException e) {
+ // fall through to the error response below
+ }
+ if (!hresponse.isCommitted()) hresponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "admin login required");
+ return false;
+ }
+
+ /**
+ * Collect all LLM endpoints from the stored configuration together with their api_key:
+ * the inference system used on /LLMSelection_p.html and all production model rows.
+ */
+ private static Map<String, String> configuredHoststubs() {
+ final Switchboard sb = Switchboard.getSwitchboard();
+ final Map<String, String> hoststubs = new HashMap<>();
+ try {
+ final JSONObject inference = new JSONObject(new JSONTokener(sb.getConfig("ai.inference_system", "{}")));
+ putHoststub(hoststubs, inference);
+ } catch (final JSONException e) {}
+ try {
+ final JSONArray productionModels = new JSONArray(new JSONTokener(sb.getConfig("ai.production_models", "[]")));
+ for (int i = 0; i < productionModels.length(); i++) {
+ putHoststub(hoststubs, productionModels.optJSONObject(i));
+ }
+ } catch (final JSONException e) {}
+ return hoststubs;
+ }
+
+ private static void putHoststub(final Map<String, String> hoststubs, final JSONObject row) {
+ if (row == null) return;
+ String hoststub = row.optString("hoststub", "").trim();
+ while (hoststub.endsWith("/")) hoststub = hoststub.substring(0, hoststub.length() - 1);
+ if (hoststub.isEmpty()) return;
+ final String apiKey = row.optString("api_key", "").trim();
+ // do not let a row without key shadow a key from another row for the same endpoint
+ if (!hoststubs.containsKey(hoststub) || !apiKey.isEmpty()) hoststubs.put(hoststub, apiKey);
+ }
+}
diff --git a/source/net/yacy/http/servlets/OllamaTagsServlet.java b/source/net/yacy/http/servlets/OllamaTagsServlet.java
index 85dc190ca..026eb19eb 100644
--- a/source/net/yacy/http/servlets/OllamaTagsServlet.java
+++ b/source/net/yacy/http/servlets/OllamaTagsServlet.java
@@ -48,6 +48,10 @@ public class OllamaTagsServlet extends HttpServlet {
HttpServletResponse hresponse = (HttpServletResponse) response;
HttpServletRequest hrequest = (HttpServletRequest) request;
+ // admin passthrough mode: a hoststub parameter selects an LLM endpoint
+ // whose /api/tags is mirrored 1:1 (admin-only)
+ if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return;
+
// Add CORS headers
hresponse.setHeader("Access-Control-Allow-Origin", "*");
hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
diff --git a/source/net/yacy/http/servlets/OpenAIModelsServlet.java b/source/net/yacy/http/servlets/OpenAIModelsServlet.java
index c4d9f0f71..0f98fb86a 100644
--- a/source/net/yacy/http/servlets/OpenAIModelsServlet.java
+++ b/source/net/yacy/http/servlets/OpenAIModelsServlet.java
@@ -48,6 +48,10 @@ public class OpenAIModelsServlet extends HttpServlet {
HttpServletResponse hresponse = (HttpServletResponse) response;
HttpServletRequest hrequest = (HttpServletRequest) request;
+ // admin passthrough mode: a hoststub parameter selects an LLM endpoint
+ // whose /v1/models is mirrored 1:1 (admin-only)
+ if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return;
+
// Add CORS headers
hresponse.setHeader("Access-Control-Allow-Origin", "*");
hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
diff --git a/source/net/yacy/http/servlets/RAGProxyServlet.java b/source/net/yacy/http/servlets/RAGProxyServlet.java
index c41789e9c..6e0b8fd0e 100644
--- a/source/net/yacy/http/servlets/RAGProxyServlet.java
+++ b/source/net/yacy/http/servlets/RAGProxyServlet.java
@@ -55,6 +55,13 @@ import net.yacy.search.Switchboard;
/**
* This class implements a Retrieval Augmented Generation ("RAG") proxy which
* uses a YaCy search index to enrich a chat with search results.
+ *
+ * The endpoint has two operation modes (see LLMAdminProxyServlet for the concept):
+ * without a "hoststub" request parameter it is the public, AIShield-regulated RAG chat
+ * proxy implemented in this class, where the target LLM comes from the server-side
+ * configuration. With a "hoststub" parameter the request is handed over to the
+ * admin-only passthrough proxy which mirrors the given endpoint 1:1.
+ *
* You can test this using a curl command:
curl -X POST "http://localhost:8090/v1/chat/completions"\
-s -H "Content-Type: application/json"\
@@ -92,6 +99,10 @@ public class RAGProxyServlet extends HttpServlet {
HttpServletResponse hresponse = (HttpServletResponse) response;
HttpServletRequest hrequest = (HttpServletRequest) request;
+ // admin passthrough mode: a hoststub parameter selects a configured LLM endpoint
+ // which is mirrored 1:1 (admin-only, no RAG augmentation, no AIShield rules)
+ if (LLMAdminProxyServlet.tryHandle(hrequest, hresponse)) return;
+
// Add CORS headers
hresponse.setHeader("Access-Control-Allow-Origin", "*");
hresponse.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");