@zereight/mcp-gitlab 2.1.29 → 2.1.38

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.
Files changed (71) hide show
  1. package/README.ko.md +72 -20
  2. package/README.md +120 -82
  3. package/README.zh-CN.md +72 -20
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +850 -490
  7. package/build/schemas.js +115 -28
  8. package/build/scripts/check-skill-sync.js +137 -0
  9. package/build/scripts/generate-tool-docs.js +8 -3
  10. package/build/server/metrics.js +84 -0
  11. package/build/server/transport-mode.js +25 -0
  12. package/build/test/callback-proxy-tests.js +1 -1
  13. package/build/test/client-pool-test.js +1 -1
  14. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  15. package/build/test/dynamic-api-url-test.js +4 -4
  16. package/build/test/dynamic-routing-tests.js +4 -4
  17. package/build/test/group-milestone-schema.test.js +45 -0
  18. package/build/test/mcp-oauth-tests.js +29 -4
  19. package/build/test/mcp-server-name.test.js +87 -0
  20. package/build/test/multi-server-test.js +3 -3
  21. package/build/test/no-proxy-integration-test.js +1 -1
  22. package/build/test/nullable-gitlab-response-fields.test.js +22 -0
  23. package/build/test/path-segment-encoding.test.js +11 -0
  24. package/build/test/remote-auth-simple-test.js +226 -199
  25. package/build/test/server/metrics.test.js +47 -0
  26. package/build/test/sse-auth-guard.test.js +2 -2
  27. package/build/test/stateless/session-id-integration.test.js +3 -3
  28. package/build/test/streamable-http-concurrent-session.test.js +64 -1
  29. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  30. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  31. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  32. package/build/test/test-ci-catalog.js +1 -1
  33. package/build/test/test-ci-lint.js +1 -1
  34. package/build/test/test-ci-variables.js +8 -5
  35. package/build/test/test-dependency-proxy.js +11 -7
  36. package/build/test/test-deployment-tools.js +16 -2
  37. package/build/test/test-download-attachment.js +1 -1
  38. package/build/test/test-get-file-blame.js +1 -1
  39. package/build/test/test-geteffectiveprojectid.js +221 -8
  40. package/build/test/test-issue-description-patch.js +1 -1
  41. package/build/test/test-job-artifacts.js +1 -1
  42. package/build/test/test-list-issues.js +1 -1
  43. package/build/test/test-list-merge-requests.js +1 -1
  44. package/build/test/test-list-project-members.js +1 -1
  45. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  46. package/build/test/test-merge-request-pipelines.js +1 -1
  47. package/build/test/test-mr-diffs-filter.js +1 -1
  48. package/build/test/test-mr-file-diffs.js +2 -2
  49. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  50. package/build/test/test-permission-mode.js +216 -0
  51. package/build/test/test-protected-branches.js +23 -4
  52. package/build/test/test-remote-downloads.js +2 -2
  53. package/build/test/test-search-code.js +1 -1
  54. package/build/test/test-tags.js +1 -1
  55. package/build/test/test-todos.js +1 -1
  56. package/build/test/test-token-optimizations.js +3 -3
  57. package/build/test/test-toolset-filtering.js +3 -3
  58. package/build/test/test-update-issue-slim.js +141 -0
  59. package/build/test/test-upload-markdown.js +1 -1
  60. package/build/test/utils/download-token.test.js +35 -0
  61. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  62. package/build/test/utils/graphql-query.test.js +64 -1
  63. package/build/test/utils/mock-gitlab-server.js +24 -31
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +93 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/version-check.js +40 -0
  71. package/package.json +5 -4
package/README.zh-CN.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [English](./README.md) | [한국어](./README.ko.md) | [简体中文](./README.zh-CN.md)
4
4
 
5
- > **新功能**:支持带连接池的动态 GitLab API URL。详情请参阅 [Dynamic API URL 文档](docs/configuration/dynamic-api-url.md)。
5
+ 📖 **[文档 →](https://zereight.github.io/gitlab-mcp/)** 设置指南、环境变量和完整工具参考请查看托管文档站点。
6
6
 
7
- [![Star History Chart](https://api.star-history.com/svg?repos=zereight/gitlab-mcp&type=Date)](https://www.star-history.com/#zereight/gitlab-mcp&Date)
7
+ [![Star History Chart](./assets/star-history.png)](https://www.star-history.com/?repos=zereight%2Fgitlab-mcp&type=date&legend=top-left)
8
8
 
9
9
  ## @zereight/mcp-gitlab
10
10
 
@@ -32,6 +32,8 @@
32
32
  - [基于 JSON 的 MCP 客户端设置指南](./docs/clients/json-clients.md) - 适用于 Factory AI Droid、OpenClaw 和 OpenCode 风格客户端
33
33
  - [OAuth2 认证设置指南](./docs/auth/oauth-setup.md)
34
34
  - [环境变量参考](./docs/configuration/environment-variables.md)
35
+ - [Stateless Mode — 多 Pod HPA](./docs/configuration/stateless-mode.md)
36
+ - [自定义 Agent 与多 PAT 设置](./docs/auth/custom-agent-multiple-pat.md)
35
37
 
36
38
  ## 使用方法
37
39
 
@@ -63,7 +65,13 @@
63
65
 
64
66
  最简单的本地设置可以从 Personal Access Token 开始。基于浏览器的本地认证使用 OAuth2。远程或多用户部署请继续查看下面的 MCP OAuth 和远程授权部分。
65
67
 
66
- 先全局安装一次服务器:
68
+ 安装服务器:
69
+
70
+ ```shell
71
+ brew install zereight/gitlab-mcp/zereight-mcp-gitlab
72
+ ```
73
+
74
+ 也可以使用 npm 安装:
67
75
 
68
76
  ```shell
69
77
  npm install -g @zereight/mcp-gitlab
@@ -71,7 +79,7 @@ npm install -g @zereight/mcp-gitlab
71
79
 
72
80
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
73
81
 
74
- 如果不想全局安装,请固定 `npx` 版本,例如 `npx -y @zereight/mcp-gitlab@2.1.29`。
82
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.37`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
75
83
 
76
84
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
77
85
 
@@ -93,14 +101,17 @@ npm install -g @zereight/mcp-gitlab
93
101
 
94
102
  - `--token` - GitLab Personal Access Token(替代 `GITLAB_PERSONAL_ACCESS_TOKEN`)
95
103
  - `--api-url` - GitLab API URL(替代 `GITLAB_API_URL`)
96
- - `--read-only=true` - 启用只读模式(替代 `GITLAB_READ_ONLY_MODE`)
104
+ - `--read-only=true` - 启用只读模式(替代 `GITLAB_READ_ONLY_MODE`,已弃用 — 推荐 `--permission-mode=readonly`)
105
+ - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`)
97
106
  - `--use-wiki=true` - 启用 Wiki API(替代 `USE_GITLAB_WIKI`,旧版 — 推荐 `GITLAB_TOOLSETS=wiki`)
98
107
  - `--use-milestone=true` - 启用里程碑 API(替代 `USE_MILESTONE`,旧版 — 推荐 `GITLAB_TOOLSETS=milestones`)
99
108
  - `--use-pipeline=true` - 启用流水线 API(替代 `USE_PIPELINE`,旧版 — 推荐 `GITLAB_TOOLSETS=pipelines`)
109
+ - `--disable-version-check=true` - 关闭启动时的新版本提示(替代 `GITLAB_DISABLE_VERSION_CHECK`)
100
110
 
101
111
  CLI 参数优先于环境变量。
102
112
 
103
- > **细粒度工具过滤:**除了全开/全关的 `GITLAB_READ_ONLY_MODE`,还可以用
113
+ > **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新并阻止所有删除工具,
114
+ > 或使用 `GITLAB_PERMISSION_MODE=readonly` 只读运行。还可以用
104
115
  > `GITLAB_TOOLSETS=<group,…>` 启用工具分组,用 `GITLAB_TOOLS=<tool,…>` 白名单启用单个工具
105
116
  > (例如:只读分组 + 少数几个写工具),用 `GITLAB_DENIED_TOOLS_REGEX` 按正则屏蔽工具。
106
117
  > 旧版 `USE_GITLAB_WIKI` / `USE_MILESTONE` / `USE_PIPELINE` 标志仅为向后兼容保留。
@@ -114,9 +125,10 @@ docker run -i --rm \
114
125
  -e HOST=0.0.0.0 \
115
126
  -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
116
127
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
117
- -e GITLAB_READ_ONLY_MODE=true \
128
+ -e GITLAB_PERMISSION_MODE=readonly \
118
129
  -e GITLAB_TOOLSETS=wiki,milestones,pipelines \
119
130
  -e SSE=true \
131
+ -e SSE_AUTH_TOKEN=your_mcp_sse_token \
120
132
  -p 3333:3002 \
121
133
  zereight050/gitlab-mcp
122
134
  ```
@@ -126,7 +138,10 @@ docker run -i --rm \
126
138
  "mcpServers": {
127
139
  "gitlab": {
128
140
  "type": "sse",
129
- "url": "http://localhost:3333/sse"
141
+ "url": "http://localhost:3333/sse",
142
+ "headers": {
143
+ "Authorization": "Bearer your_mcp_sse_token"
144
+ }
130
145
  }
131
146
  }
132
147
  }
@@ -137,9 +152,9 @@ docker run -i --rm \
137
152
  ```shell
138
153
  docker run -i --rm \
139
154
  -e HOST=0.0.0.0 \
140
- -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
155
+ -e REMOTE_AUTHORIZATION=true \
141
156
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
142
- -e GITLAB_READ_ONLY_MODE=true \
157
+ -e GITLAB_PERMISSION_MODE=readonly \
143
158
  -e GITLAB_TOOLSETS=wiki,milestones,pipelines \
144
159
  -e STREAMABLE_HTTP=true \
145
160
  -p 3333:3002 \
@@ -151,7 +166,10 @@ docker run -i --rm \
151
166
  "mcpServers": {
152
167
  "gitlab": {
153
168
  "type": "streamable-http",
154
- "url": "http://localhost:3333/mcp"
169
+ "url": "http://localhost:3333/mcp",
170
+ "headers": {
171
+ "Authorization": "Bearer glpat-..."
172
+ }
155
173
  }
156
174
  }
157
175
  }
@@ -197,6 +215,8 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
197
215
  | `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`) |
198
216
  | `GITLAB_OAUTH_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌(替代已废弃的 `GITLAB_ALLOWED_GROUPS`) |
199
217
 
218
+ 当 `STREAMABLE_HTTP=true` 时,服务端 GitLab 凭据(`GITLAB_PERSONAL_ACCESS_TOKEN`、`GITLAB_JOB_TOKEN`、`GITLAB_AUTH_COOKIE_PATH` 或 `GITLAB_USE_OAUTH`)需要 `REMOTE_AUTHORIZATION=true`、`GITLAB_MCP_OAUTH=true` 或 `STREAMABLE_HTTP_AUTH_TOKEN`。
219
+
200
220
  > **排查 `Unregistered redirect_uri`**
201
221
  >
202
222
  > 检查浏览器 URL 中的 `redirect_uri`。如果它指向客户端 callback,例如 `http://127.0.0.1:xxxxx/.../callback`,请启用:
@@ -241,12 +261,19 @@ MCP 客户端配置:
241
261
 
242
262
  **请求头优先级**:`Private-Token` > `JOB-TOKEN` > `Authorization: Bearer`
243
263
 
244
- | 环境变量 | 必需 | 说明 |
245
- | ------------------------ | ---- | ------------------------------------------------------- |
246
- | `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
247
- | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
248
- | `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
249
- | `GITLAB_ALLOWED_HOSTS` | 可选 | 逗号分隔的允许主机;`GITLAB_API_URL` 中的主机始终允许 |
264
+ | 环境变量 | 必需 | 说明 |
265
+ | ---------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------- |
266
+ | `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
267
+ | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
268
+ | `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
269
+ | `GITLAB_ALLOWED_HOSTS` | 可选 | 允许的 `X-GitLab-API-URL` 主机逗号分隔列表;`GITLAB_API_URL` 中的主机始终允许 |
270
+ | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 可选 | 仅允许未认证的 `initialize`、`notifications/initialized`、`tools/list`(工具调用仍需认证) |
271
+ | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | 可选 | 用于 DNS rebinding 防护的允许 `/mcp` 主机/来源值 |
272
+ | `MCP_TRUST_PROXY` | 可选 | 在反向代理后信任 `Forwarded` / `X-Forwarded-*` 请求头(下载 URL、Express `req.ip`、`/mcp` IP 速率限制、OAuth 速率限制) |
273
+
274
+ `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY=true` 适用于在用户提供 GitLab token 之前需要检查工具元数据的 MCP 网关或管理 UI。除非你的部署可以安全地暴露工具列表,否则请保持禁用。
275
+
276
+ 当未设置 `MCP_SERVER_URL` 时,远程下载 URL 会回退到本地服务器地址。仅当服务器通过受信任的反向代理可达且已阻止客户端直接访问 MCP 服务器时,才设置 `MCP_TRUST_PROXY=true`。这会为 Streamable HTTP 和 SSE 启用 Express `trust proxy`,从 `Forwarded` / `X-Forwarded-Proto` / `X-Forwarded-Host` / `X-Forwarded-Prefix` 派生公共下载 URL,并在代理通过 `X-Forwarded-For` 发送带客户端端口的地址(例如 `1.2.3.4:5678`)时保持 OAuth 端点速率限制可用。引入此标志后,现有 OAuth+代理部署必须显式设置。
250
277
 
251
278
  **示例请求头:**
252
279
 
@@ -272,7 +299,9 @@ Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx
272
299
 
273
300
  - **本地 PAT**:`GITLAB_PERSONAL_ACCESS_TOKEN`, `GITLAB_API_URL`
274
301
  - **本地 OAuth**:`GITLAB_USE_OAUTH=true`, `GITLAB_OAUTH_CLIENT_ID`, `GITLAB_OAUTH_REDIRECT_URI`, `GITLAB_API_URL`
275
- - **远程多用户 HTTP**:`STREAMABLE_HTTP=true`, `REMOTE_AUTHORIZATION=true`, `HOST`, `PORT`
302
+ - **远程多用户 HTTP**:`STREAMABLE_HTTP=true`, `REMOTE_AUTHORIZATION=true`(或 `GITLAB_MCP_OAUTH=true`), `MCP_TRUST_PROXY=true`(反向代理后), `MAX_REQUESTS_PER_MINUTE=300`, `MCP_SERVER_URL` 或 `MCP_ALLOWED_HOSTS`, `HOST`, `PORT`
303
+ - **并行运行多个部署**:为每个实例设置不同的 `MCP_SERVER_NAME`(例如 `gitlab-selfhosted-readonly`),以便在客户端、日志和遥测数据中区分它们
304
+ - **多 Pod HPA(stateless)**:上述配置 + `OAUTH_STATELESS_MODE=true`, `OAUTH_STATELESS_SECRET`(所有 Pod 相同)。参见 [Stateless Mode](./docs/configuration/stateless-mode.md)。
276
305
 
277
306
  常用变量:
278
307
 
@@ -280,8 +309,15 @@ Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx
280
309
  - `GITLAB_PERSONAL_ACCESS_TOKEN`
281
310
  - `GITLAB_USE_OAUTH`
282
311
  - `REMOTE_AUTHORIZATION`
312
+ - `MCP_TRUST_PROXY`
313
+ - `MAX_REQUESTS_PER_MINUTE`
314
+ - `MAX_SESSIONS`
315
+ - `MCP_ALLOWED_HOSTS`
316
+ - `MCP_ALLOWED_ORIGINS`
283
317
  - `GITLAB_MCP_OAUTH`
284
318
  - `GITLAB_OAUTH_CALLBACK_PROXY`
319
+ - `OAUTH_STATELESS_MODE`
320
+ - `OAUTH_STATELESS_SECRET`
285
321
 
286
322
  参考文档还包含:
287
323
 
@@ -309,7 +345,7 @@ docker run -d \
309
345
  -e STREAMABLE_HTTP=true \
310
346
  -e REMOTE_AUTHORIZATION=true \
311
347
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
312
- -e GITLAB_READ_ONLY_MODE=true \
348
+ -e GITLAB_PERMISSION_MODE=readonly \
313
349
  -e SESSION_TIMEOUT_SECONDS=3600 \
314
350
  -p 3333:3002 \
315
351
  zereight050/gitlab-mcp
@@ -352,7 +388,7 @@ token 按会话存储(由 `mcp-session-id` 请求头标识),并在同一
352
388
  - 每个会话相互隔离。一个会话的 token 不能访问另一个会话的数据。会话关闭后 token 会自动清理。
353
389
  - **会话超时:** 认证 token 在 `SESSION_TIMEOUT_SECONDS`(默认 1 小时)无活动后过期。超时后,客户端必须再次发送认证请求头。传输会话仍保持活动。
354
390
  - 每个请求都会重置该会话的超时计时器。
355
- - **Rate limiting:** 每个会话限制为每分钟 `MAX_REQUESTS_PER_MINUTE` 次请求(默认 60)。
391
+ - **Rate limiting:** `/mcp` 请求按客户端 IP 限制为每分钟 `MAX_REQUESTS_PER_MINUTE` 次;使用 OAuth 或远程授权时还按 MCP 会话限制(默认 60)。详见 [environment-variables.md](docs/configuration/environment-variables.md#max_requests_per_minute)。
356
392
  - **Capacity limit:** 服务器最多接受 `MAX_SESSIONS` 个并发会话(默认 1000)。
357
393
 
358
394
  ### MCP OAuth 设置(Claude.ai Native OAuth)
@@ -456,6 +492,22 @@ npx skills add zereight/gitlab-mcp --skill gitlab-mcp-skill
456
492
 
457
493
  完整工具列表请参考英文 README 的 [Tools 部分](./README.md#tools-%EF%B8%8F)。当前服务器提供合并请求、议题、流水线、部署、环境、制品、里程碑、Wiki、仓库、发布、用户、事件、work item、webhook、代码搜索和 GraphQL 执行相关工具。
458
494
 
495
+ ### Wiki 页面标题与 slug
496
+
497
+ GitLab 会根据 wiki 页面标题推导其 **slug**(即 URL,`/-/wikis/<slug>`)。因此向 `update_wiki_page` / `update_group_wiki_page` 传入 `title` 会**重命名页面并改变其 URL**——对于嵌套页面,还可能把页面移动到不同的路径——从而导致已有链接失效。
498
+
499
+ 若只想修改**显示标题**而保持 URL 不变,请**不要**传入 `title`,而是把显示标题写入页面内容的 YAML front matter 并更新内容:
500
+
501
+ ```markdown
502
+ ---
503
+ title: 我的自定义显示标题
504
+ ---
505
+
506
+ 页面正文…
507
+ ```
508
+
509
+ GitLab 会保持 slug/URL 不变,并在界面中显示 front matter 中的标题。读取时对 `get_wiki_page` 传入 `render_html: true`,即可填充 `front_matter` 字段——而普通的 `title` 字段始终反映由 slug 推导的值。
510
+
459
511
  ## 测试 🧪
460
512
 
461
513
  项目包含完整测试覆盖,包括远程授权:
package/build/config.js CHANGED
@@ -28,9 +28,22 @@ export const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
28
28
  // Behavior flags
29
29
  // ---------------------------------------------------------------------------
30
30
  export const GITLAB_READ_ONLY_MODE = getConfig("read-only", "GITLAB_READ_ONLY_MODE") === "true";
31
+ const PERMISSION_MODES = ["readonly", "modify", "full"];
32
+ export const GITLAB_PERMISSION_MODE = (() => {
33
+ const raw = getConfig("permission-mode", "GITLAB_PERMISSION_MODE");
34
+ if (raw !== undefined && !PERMISSION_MODES.includes(raw)) {
35
+ throw new Error(`Invalid GITLAB_PERMISSION_MODE: "${raw}". Expected one of: ${PERMISSION_MODES.join(", ")}`);
36
+ }
37
+ // Legacy GITLAB_READ_ONLY_MODE=true always wins (most restrictive)
38
+ if (GITLAB_READ_ONLY_MODE) {
39
+ return "readonly";
40
+ }
41
+ return raw ?? "full";
42
+ })();
31
43
  export const USE_GITLAB_WIKI = getConfig("use-wiki", "USE_GITLAB_WIKI") === "true";
32
44
  export const USE_MILESTONE = getConfig("use-milestone", "USE_MILESTONE") === "true";
33
45
  export const USE_PIPELINE = getConfig("use-pipeline", "USE_PIPELINE") === "true";
46
+ export const GITLAB_DISABLE_VERSION_CHECK = getConfig("disable-version-check", "GITLAB_DISABLE_VERSION_CHECK") === "true";
34
47
  // ---------------------------------------------------------------------------
35
48
  // Tool filtering
36
49
  // ---------------------------------------------------------------------------
@@ -0,0 +1,199 @@
1
+ import { decryptDownloadToken } from "../utils/download-token.js";
2
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
3
+ function canonicalizeQueryParams(params) {
4
+ const sortedKeys = Object.keys(params).sort();
5
+ const normalized = {};
6
+ for (const key of sortedKeys) {
7
+ normalized[key] = params[key];
8
+ }
9
+ return JSON.stringify(normalized);
10
+ }
11
+ /**
12
+ * Register the /downloads/:type proxy endpoint on an Express app.
13
+ * Streams GitLab API responses directly to the client. Auth is read from
14
+ * an encrypted `_token` query param (self-contained URL) or from request headers.
15
+ */
16
+ export function registerDownloadProxy(app, deps) {
17
+ const downloadRateLimits = {};
18
+ let lastEviction = Date.now();
19
+ const downloadTimeoutMs = deps.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
20
+ const checkDownloadRateLimit = (token) => {
21
+ const now = Date.now();
22
+ // Evict expired entries every 60s to prevent unbounded growth
23
+ if (now - lastEviction > 60000) {
24
+ for (const key of Object.keys(downloadRateLimits)) {
25
+ if (now > downloadRateLimits[key].resetAt)
26
+ delete downloadRateLimits[key];
27
+ }
28
+ lastEviction = now;
29
+ }
30
+ const entry = downloadRateLimits[token];
31
+ if (!entry || now > entry.resetAt) {
32
+ downloadRateLimits[token] = { count: 1, resetAt: now + 60000 };
33
+ return true;
34
+ }
35
+ if (entry.count >= deps.maxRequestsPerMinute)
36
+ return false;
37
+ entry.count++;
38
+ return true;
39
+ };
40
+ app.get("/downloads/:type", async (req, res) => {
41
+ const headers = { Accept: "application/octet-stream" };
42
+ let rateLimitKey;
43
+ // Try embedded encrypted token first (self-contained URL), then headers
44
+ const encryptedToken = req.query._token;
45
+ let tokenApiUrl;
46
+ if (encryptedToken) {
47
+ const decrypted = decryptDownloadToken(encryptedToken);
48
+ if (!decrypted) {
49
+ res.status(401).json({ error: "Invalid or expired download token" });
50
+ return;
51
+ }
52
+ // Verify resource binding — token must match the requested type and params
53
+ if (decrypted.resourceType || decrypted.resourceParams) {
54
+ const { type } = req.params;
55
+ const queryParams = {};
56
+ for (const [k, v] of Object.entries(req.query)) {
57
+ if (k !== "_token" && typeof v === "string")
58
+ queryParams[k] = v;
59
+ }
60
+ if (decrypted.resourceType !== type ||
61
+ canonicalizeQueryParams(decrypted.resourceParams ?? {}) !==
62
+ canonicalizeQueryParams(queryParams)) {
63
+ res.status(403).json({ error: "Download token does not match the requested resource" });
64
+ return;
65
+ }
66
+ }
67
+ headers[decrypted.header] = decrypted.token;
68
+ rateLimitKey = decrypted.token;
69
+ tokenApiUrl = decrypted.apiUrl;
70
+ }
71
+ else {
72
+ const privateToken = req.headers["private-token"];
73
+ const jobToken = req.headers["job-token"];
74
+ const authHeader = req.headers["authorization"];
75
+ if (privateToken) {
76
+ headers["Private-Token"] = privateToken;
77
+ rateLimitKey = privateToken;
78
+ }
79
+ else if (jobToken) {
80
+ headers["JOB-TOKEN"] = jobToken;
81
+ rateLimitKey = jobToken;
82
+ }
83
+ else if (authHeader) {
84
+ headers["Authorization"] = authHeader;
85
+ rateLimitKey = authHeader;
86
+ }
87
+ else {
88
+ res.status(401).json({ error: "Authentication required" });
89
+ return;
90
+ }
91
+ }
92
+ if (!checkDownloadRateLimit(rateLimitKey)) {
93
+ res.status(429).json({ error: "Rate limit exceeded" });
94
+ return;
95
+ }
96
+ // API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
97
+ let apiUrl = deps.defaultApiUrl;
98
+ const requestedApiUrl = tokenApiUrl || req.headers["x-gitlab-api-url"]?.trim();
99
+ if (deps.enableDynamicApiUrl && requestedApiUrl) {
100
+ try {
101
+ apiUrl = deps.resolveTrustedGitLabApiUrl(requestedApiUrl);
102
+ }
103
+ catch {
104
+ res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
105
+ return;
106
+ }
107
+ }
108
+ const { type } = req.params;
109
+ let gitlabUrl;
110
+ try {
111
+ switch (type) {
112
+ case "job-artifacts": {
113
+ const { project_id, job_id } = req.query;
114
+ if (!project_id || !job_id) {
115
+ res.status(400).json({ error: "project_id and job_id are required" });
116
+ return;
117
+ }
118
+ const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
119
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
120
+ break;
121
+ }
122
+ case "attachment": {
123
+ const { project_id, secret, filename } = req.query;
124
+ if (!project_id || !secret || !filename) {
125
+ res.status(400).json({ error: "project_id, secret, and filename are required" });
126
+ return;
127
+ }
128
+ const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
129
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPath(filename)}`;
130
+ break;
131
+ }
132
+ case "release-asset": {
133
+ const { project_id, tag_name, direct_asset_path } = req.query;
134
+ if (!project_id || !tag_name || !direct_asset_path) {
135
+ res
136
+ .status(400)
137
+ .json({ error: "project_id, tag_name, and direct_asset_path are required" });
138
+ return;
139
+ }
140
+ const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
141
+ gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
142
+ break;
143
+ }
144
+ default:
145
+ res.status(400).json({ error: `Unknown download type: ${type}` });
146
+ return;
147
+ }
148
+ }
149
+ catch (e) {
150
+ // getEffectiveProjectId throws on access-denied
151
+ const message = e instanceof Error ? e.message : "Invalid parameters";
152
+ res.status(403).json({ error: message });
153
+ return;
154
+ }
155
+ const controller = new AbortController();
156
+ const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
157
+ try {
158
+ const agent = deps.getAgentFunctionForUrl(apiUrl);
159
+ const gitlabResponse = await deps.fetch(gitlabUrl, {
160
+ headers,
161
+ agent,
162
+ signal: controller.signal,
163
+ });
164
+ if (!gitlabResponse.ok) {
165
+ res.status(gitlabResponse.status).json({
166
+ error: `GitLab API error: ${gitlabResponse.status} ${gitlabResponse.statusText}`,
167
+ });
168
+ return;
169
+ }
170
+ const contentType = gitlabResponse.headers.get("content-type");
171
+ const contentDisposition = gitlabResponse.headers.get("content-disposition");
172
+ const contentLength = gitlabResponse.headers.get("content-length");
173
+ if (contentType)
174
+ res.setHeader("Content-Type", contentType);
175
+ if (contentDisposition)
176
+ res.setHeader("Content-Disposition", contentDisposition);
177
+ if (contentLength)
178
+ res.setHeader("Content-Length", contentLength);
179
+ if (gitlabResponse.body) {
180
+ gitlabResponse.body.pipe(res);
181
+ }
182
+ else {
183
+ res.status(502).json({ error: "No response body from GitLab" });
184
+ }
185
+ }
186
+ catch (error) {
187
+ deps.logger.error({ err: error }, "Download proxy error");
188
+ if (!res.headersSent) {
189
+ const message = error instanceof Error && error.name === "AbortError"
190
+ ? "GitLab download timed out"
191
+ : "Failed to proxy download from GitLab";
192
+ res.status(502).json({ error: message });
193
+ }
194
+ }
195
+ finally {
196
+ clearTimeout(timeout);
197
+ }
198
+ });
199
+ }