zengate 1.0.0 → 1.0.2

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  [![CI](https://github.com/developingchet/zengate/actions/workflows/ci.yml/badge.svg)](https://github.com/developingchet/zengate/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/zengate)](https://www.npmjs.com/package/zengate)
5
5
  [![Docker Hub](https://img.shields.io/docker/v/developingchet/zengate?label=docker&sort=semver)](https://hub.docker.com/r/developingchet/zengate)
6
+ [![Socket](https://socket.dev/api/badge/npm/package/zengate)](https://socket.dev/npm/package/zengate)
6
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
8
 
8
9
  An **OpenAI-compatible API for OpenCode's free Zen models: no Zen account or upstream API key needed.**
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "zengate",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Keyless OpenAI-compatible API (Chat Completions + Responses) for OpenCode's free Zen models, served through the real OpenCode CLI.",
5
+ "author": {
6
+ "name": "developingchet",
7
+ "url": "https://github.com/developingchet"
8
+ },
5
9
  "homepage": "https://github.com/developingchet/zengate#readme",
6
10
  "repository": {
7
11
  "type": "git",
@@ -46,12 +50,16 @@
46
50
  "self-hosted"
47
51
  ],
48
52
  "license": "MIT",
49
- "engines": {
50
- "node": ">=24.0.0"
51
- },
52
53
  "dependencies": {
53
- "cors": "^2.8.5",
54
54
  "express": "^5.1.0",
55
55
  "opencode-ai": "^1.18.32"
56
+ },
57
+ "overrides": {
58
+ "es-define-property": "npm:@socketregistry/es-define-property@^1",
59
+ "safer-buffer": "npm:@socketregistry/safer-buffer@^1",
60
+ "side-channel": "npm:@socketregistry/side-channel@^1"
61
+ },
62
+ "engines": {
63
+ "node": ">=24.0.0"
56
64
  }
57
65
  }
package/src/server/app.js CHANGED
@@ -1,8 +1,8 @@
1
- import cors from 'cors';
2
1
  import express from 'express';
3
2
  import { chatCompletionsHandler } from '../openai/chat.js';
4
3
  import { modelsHandlers } from '../openai/models.js';
5
4
  import { responsesHandlers } from '../openai/responses.js';
5
+ import { corsMiddleware } from './cors.js';
6
6
  import { ApiError, sendError, toApiError } from './errors.js';
7
7
  import { createLimiter } from './limiter.js';
8
8
  import { createMetrics } from './metrics.js';
@@ -68,7 +68,7 @@ export function createApp({ config, logger, backend, hub, catalog, runner, store
68
68
  app.set('trust proxy', config.TRUST_PROXY || false);
69
69
  app.use(requestId, securityHeaders, metrics.middleware, requestLogger(logger));
70
70
  if (config.CORS_ORIGINS.length) {
71
- app.use(cors({ origin: config.CORS_ORIGINS, exposedHeaders: EXPOSED_HEADERS, maxAge: 600 }));
71
+ app.use(corsMiddleware({ origins: config.CORS_ORIGINS, exposedHeaders: EXPOSED_HEADERS, maxAge: 600 }));
72
72
  }
73
73
 
74
74
  app.get('/health', (req, res) => res.json({ status: 'ok' }));
@@ -0,0 +1,33 @@
1
+ const ALLOWED_METHODS = 'GET,HEAD,POST,DELETE,OPTIONS';
2
+ // A comma-separated list of HTTP header names (RFC 9110 tokens).
3
+ const HEADER_LIST = /^[!#$%&'*+.^`|~\w-]+(?:\s*,\s*[!#$%&'*+.^`|~\w-]+)*$/;
4
+
5
+ /**
6
+ * CORS for an explicit list of browser origins. Origins are matched exactly,
7
+ * credentials are never allowed, and preflights are answered directly.
8
+ * @param {{ origins: string[], exposedHeaders: string[], maxAge: number }} options
9
+ */
10
+ export function corsMiddleware({ origins, exposedHeaders, maxAge }) {
11
+ const allowed = new Set(origins);
12
+ const exposed = exposedHeaders.join(',');
13
+
14
+ return (req, res, next) => {
15
+ const origin = req.headers.origin;
16
+ if (!origin) return next();
17
+ res.vary('Origin');
18
+ const granted = allowed.has(origin);
19
+ const isPreflight = req.method === 'OPTIONS' && Boolean(req.headers['access-control-request-method']);
20
+
21
+ if (!isPreflight) {
22
+ if (granted) res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Expose-Headers': exposed });
23
+ return next();
24
+ }
25
+ if (granted) {
26
+ const requested = String(req.headers['access-control-request-headers'] || '').trim();
27
+ res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': ALLOWED_METHODS, 'Access-Control-Max-Age': String(maxAge) });
28
+ if (requested && HEADER_LIST.test(requested)) res.set('Access-Control-Allow-Headers', requested);
29
+ res.vary('Access-Control-Request-Headers');
30
+ }
31
+ return res.status(204).end();
32
+ };
33
+ }
@@ -3,17 +3,25 @@ import { ApiError, sendError } from './errors.js';
3
3
 
4
4
  const PUBLIC_PATHS = new Set(['/health', '/ready']);
5
5
 
6
- /** Constant-time comparison; hashing first hides the key length. */
7
- function safeEqual(a, b) {
8
- const ha = crypto.createHash('sha256').update(a).digest();
9
- const hb = crypto.createHash('sha256').update(b).digest();
10
- return crypto.timingSafeEqual(ha, hb);
6
+ const BEARER = 'bearer';
7
+
8
+ /**
9
+ * Constant-time comparison of the key bytes. Only the length can differ in
10
+ * timing, and key lengths are not secret (generated keys have a fixed format).
11
+ */
12
+ function safeEqual(presented, expected) {
13
+ const a = Buffer.from(presented);
14
+ const b = Buffer.from(expected);
15
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
11
16
  }
12
17
 
18
+ /** The key from `Authorization: Bearer <key>`, parsed without a backtracking regex. */
13
19
  function presentedKey(req) {
14
- const header = req.headers.authorization || '';
15
- const match = /^Bearer\s+(.+)$/i.exec(header);
16
- return match ? match[1].trim() : '';
20
+ const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
21
+ const scheme = header.slice(0, BEARER.length);
22
+ const separator = header.charAt(BEARER.length);
23
+ if (scheme.toLowerCase() !== BEARER || !/\s/.test(separator)) return '';
24
+ return header.slice(BEARER.length + 1).trim();
17
25
  }
18
26
 
19
27
  /**
@@ -25,9 +33,11 @@ export function authMiddleware({ apiKeys, allowNoAuth, onFailure }) {
25
33
  req.clientId = 'anonymous';
26
34
  if (allowNoAuth || PUBLIC_PATHS.has(req.path) || req.method === 'OPTIONS') return next();
27
35
  const key = presentedKey(req);
28
- if (key && apiKeys.some((candidate) => safeEqual(key, candidate))) {
29
- // Opaque per-key id: scopes stored responses to the key that made them.
30
- req.clientId = crypto.createHash('sha256').update(key).digest('hex').slice(0, 32);
36
+ const index = key ? apiKeys.findIndex((candidate) => safeEqual(key, candidate)) : -1;
37
+ if (index !== -1) {
38
+ // Names the matched key without deriving anything from it; scopes
39
+ // stored responses to the key that made them.
40
+ req.clientId = `key_${index}`;
31
41
  return next();
32
42
  }
33
43
  onFailure?.();