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 +1 -0
- package/package.json +13 -5
- package/src/server/app.js +2 -2
- package/src/server/cors.js +33 -0
- package/src/server/middleware.js +21 -11
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://github.com/developingchet/zengate/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/zengate)
|
|
5
5
|
[](https://hub.docker.com/r/developingchet/zengate)
|
|
6
|
+
[](https://socket.dev/npm/package/zengate)
|
|
6
7
|
[](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.
|
|
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(
|
|
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
|
+
}
|
package/src/server/middleware.js
CHANGED
|
@@ -3,17 +3,25 @@ import { ApiError, sendError } from './errors.js';
|
|
|
3
3
|
|
|
4
4
|
const PUBLIC_PATHS = new Set(['/health', '/ready']);
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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?.();
|