@openhoo/hoopilot 0.9.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2618,7 +2618,8 @@ var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
2618
2618
  // src/server.ts
2619
2619
  var DEFAULT_HOST = "127.0.0.1";
2620
2620
  var DEFAULT_PORT = 4141;
2621
- var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Browser-origin requests require HOOPILOT_API_KEY unless the Origin is loopback.";
2621
+ var FORBIDDEN_BROWSER_ORIGIN_MESSAGE = "Cross-origin browser requests are blocked unless the Origin is loopback or listed in HOOPILOT_ALLOWED_ORIGINS.";
2622
+ var WELL_KNOWN_DEMO_API_KEYS = /* @__PURE__ */ new Set(["local-key"]);
2622
2623
  var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
2623
2624
  var JSON_OBJECT_MESSAGE = "Request body must be a JSON object.";
2624
2625
  var MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024;
@@ -2634,6 +2635,7 @@ var RequestBodyTooLargeError = class extends Error {
2634
2635
  function createHoopilotHandler(options = {}) {
2635
2636
  const client = new CopilotClient(options);
2636
2637
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2638
+ const allowedOrigins = parseAllowedOrigins(options.env);
2637
2639
  const logger = serverLogger(options);
2638
2640
  const metrics = options.metrics ?? new MetricsRegistry();
2639
2641
  const readUsage = createUsageReader(client, metrics);
@@ -2653,7 +2655,10 @@ function createHoopilotHandler(options = {}) {
2653
2655
  route
2654
2656
  });
2655
2657
  metrics.startRequest();
2658
+ const origin = request.headers.get("origin")?.trim() || void 0;
2659
+ const corsOrigin = resolveCorsAllowOrigin(origin, allowedOrigins);
2656
2660
  const finish = (response) => finishResponse(response, {
2661
+ corsOrigin,
2657
2662
  logger: requestLogger,
2658
2663
  method: request.method,
2659
2664
  metrics,
@@ -2663,11 +2668,11 @@ function createHoopilotHandler(options = {}) {
2663
2668
  closeConnection: bufferProxyBodies,
2664
2669
  trackStreamingBody: !bufferProxyBodies
2665
2670
  });
2666
- const browserOrigin = forbiddenBrowserOrigin(request, apiKey);
2671
+ const browserOrigin = forbiddenBrowserOrigin(origin, request, allowedOrigins);
2667
2672
  if (browserOrigin) {
2668
2673
  requestLogger.warn(
2669
2674
  { event: "http.request.forbidden_origin", origin: browserOrigin },
2670
- "blocked unauthenticated browser-origin request"
2675
+ "blocked cross-origin browser request"
2671
2676
  );
2672
2677
  return finish(jsonError(403, "forbidden_origin", FORBIDDEN_BROWSER_ORIGIN_MESSAGE));
2673
2678
  }
@@ -2793,10 +2798,17 @@ function startHoopilotServer(options = {}) {
2793
2798
  const port = normalizeServerPort(options.port ?? envValue(options.env?.PORT) ?? DEFAULT_PORT);
2794
2799
  const apiKey = options.apiKey ?? envValue(options.env?.HOOPILOT_API_KEY);
2795
2800
  const allowUnauthenticated = options.allowUnauthenticated ?? envValue(options.env?.HOOPILOT_ALLOW_UNAUTHENTICATED) === "1";
2796
- if (!isLoopbackHost(host) && !apiKey && !allowUnauthenticated) {
2797
- throw new Error(
2798
- "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2799
- );
2801
+ if (!isLoopbackHost(host)) {
2802
+ if (!apiKey && !allowUnauthenticated) {
2803
+ throw new Error(
2804
+ "Refusing to listen on a non-loopback host without HOOPILOT_API_KEY. Set an API key or pass --allow-unauthenticated."
2805
+ );
2806
+ }
2807
+ if (apiKey && isWellKnownDemoApiKey(apiKey)) {
2808
+ throw new Error(
2809
+ "Refusing to listen on a non-loopback host with a well-known demo HOOPILOT_API_KEY. Set a strong, unique API key."
2810
+ );
2811
+ }
2800
2812
  }
2801
2813
  const server = Bun.serve({
2802
2814
  fetch: createHoopilotHandler({
@@ -3109,7 +3121,6 @@ function corsHeaders() {
3109
3121
  return {
3110
3122
  "access-control-allow-headers": "anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, authorization, content-type, x-api-key, x-request-id",
3111
3123
  "access-control-allow-methods": "GET, POST, OPTIONS",
3112
- "access-control-allow-origin": "*",
3113
3124
  "access-control-expose-headers": "x-request-id"
3114
3125
  };
3115
3126
  }
@@ -3121,17 +3132,34 @@ function isAuthorized(request, apiKey) {
3121
3132
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
3122
3133
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
3123
3134
  }
3124
- function forbiddenBrowserOrigin(request, apiKey) {
3125
- if (apiKey) {
3126
- return void 0;
3127
- }
3128
- const origin = request.headers.get("origin")?.trim();
3135
+ function forbiddenBrowserOrigin(origin, request, allowedOrigins) {
3129
3136
  if (origin) {
3130
- return isLoopbackOrigin(origin) ? void 0 : origin;
3137
+ return isAllowedOrigin(origin, allowedOrigins) ? void 0 : origin;
3131
3138
  }
3132
3139
  const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
3133
3140
  return fetchSite === "cross-site" ? "cross-site" : void 0;
3134
3141
  }
3142
+ function parseAllowedOrigins(env) {
3143
+ const raw = envValue(env?.HOOPILOT_ALLOWED_ORIGINS);
3144
+ if (!raw) {
3145
+ return /* @__PURE__ */ new Set();
3146
+ }
3147
+ return new Set(
3148
+ raw.split(",").map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0)
3149
+ );
3150
+ }
3151
+ function isAllowedOrigin(origin, allowedOrigins) {
3152
+ return isLoopbackOrigin(origin) || allowedOrigins.has(origin.toLowerCase());
3153
+ }
3154
+ function resolveCorsAllowOrigin(origin, allowedOrigins) {
3155
+ if (!origin) {
3156
+ return "*";
3157
+ }
3158
+ return isAllowedOrigin(origin, allowedOrigins) ? origin : void 0;
3159
+ }
3160
+ function isWellKnownDemoApiKey(apiKey) {
3161
+ return WELL_KNOWN_DEMO_API_KEYS.has(apiKey.trim().toLowerCase());
3162
+ }
3135
3163
  function isUpstreamAuthStatus(status) {
3136
3164
  return status === 401 || status === 403;
3137
3165
  }
@@ -3191,7 +3219,12 @@ function shouldBufferProxyBodies(mode) {
3191
3219
  return process.platform === "win32" && IS_STANDALONE_BINARY;
3192
3220
  }
3193
3221
  function finishResponse(response, options) {
3194
- const withRequestId = responseWithRequestId(response, options.requestId, options.closeConnection);
3222
+ const withRequestId = responseWithRequestId(
3223
+ response,
3224
+ options.requestId,
3225
+ options.closeConnection,
3226
+ options.corsOrigin
3227
+ );
3195
3228
  const stream = isStreamingResponse(withRequestId);
3196
3229
  const status = withRequestId.status;
3197
3230
  const complete = () => {
@@ -3209,9 +3242,17 @@ function finishResponse(response, options) {
3209
3242
  complete();
3210
3243
  return withRequestId;
3211
3244
  }
3212
- function responseWithRequestId(response, requestId, closeConnection) {
3245
+ function responseWithRequestId(response, requestId, closeConnection, corsOrigin) {
3213
3246
  const headers = new Headers(response.headers);
3214
3247
  headers.set("x-request-id", requestId);
3248
+ if (corsOrigin) {
3249
+ headers.set("access-control-allow-origin", corsOrigin);
3250
+ if (corsOrigin !== "*") {
3251
+ headers.append("vary", "Origin");
3252
+ }
3253
+ } else {
3254
+ headers.delete("access-control-allow-origin");
3255
+ }
3215
3256
  if (closeConnection) {
3216
3257
  headers.set("connection", "close");
3217
3258
  }