@openma/matter-gitlab 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +10 -2
- package/dist/bond/index.d.ts +304 -0
- package/dist/bond/index.js +638 -0
- package/dist/bond/index.js.map +1 -0
- package/package.json +29 -3
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import { ProviderError } from "@openma/matter/bond";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
//#region src/bond/http.ts
|
|
4
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes a GitLab instance URL to its canonical https origin. Rejects
|
|
7
|
+
* plain http except for loopback hosts, and rejects paths, credentials,
|
|
8
|
+
* queries, and fragments.
|
|
9
|
+
*/
|
|
10
|
+
const canonicalOrigin = (input) => {
|
|
11
|
+
const url = new URL(input);
|
|
12
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
13
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw new Error("GitLab instance URL must use https");
|
|
14
|
+
if (url.username || url.password || url.search || url.hash) throw new Error("GitLab instance URL must not carry credentials, query, or fragment");
|
|
15
|
+
if (url.pathname !== "/" && url.pathname !== "") throw new Error("GitLab instance URL must be an origin without a path");
|
|
16
|
+
return url.origin.toLowerCase();
|
|
17
|
+
};
|
|
18
|
+
/** One JSON request. Never follows redirects: a redirect is an error. */
|
|
19
|
+
const requestJson = (fetchImpl, url, init) => Effect.tryPromise({
|
|
20
|
+
try: async () => {
|
|
21
|
+
const response = await fetchImpl(url, {
|
|
22
|
+
...init,
|
|
23
|
+
redirect: "manual"
|
|
24
|
+
});
|
|
25
|
+
const text = await response.text();
|
|
26
|
+
let body = void 0;
|
|
27
|
+
if (text.length > 0) try {
|
|
28
|
+
body = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
body = void 0;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
status: response.status,
|
|
34
|
+
body
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
catch: (cause) => new ProviderError({
|
|
38
|
+
message: `GitLab request failed: ${new URL(url).pathname}`,
|
|
39
|
+
rejected: false,
|
|
40
|
+
cause
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
const bearer = (token) => ({ authorization: `Bearer ${token}` });
|
|
44
|
+
/**
|
|
45
|
+
* Maps a non-2xx GitLab response to a ProviderError without echoing bodies.
|
|
46
|
+
* Only OAuth `invalid_grant` is a rejection, the one answer that means the
|
|
47
|
+
* grant itself is gone. `invalid_client`, other 4xx, and 5xx are
|
|
48
|
+
* configuration or transport problems and must not end a link.
|
|
49
|
+
*/
|
|
50
|
+
const statusError = (what, response) => {
|
|
51
|
+
const error = isRecord(response.body) && typeof response.body.error === "string" ? response.body.error : void 0;
|
|
52
|
+
return new ProviderError({
|
|
53
|
+
message: `${what} failed with HTTP ${response.status}${error === void 0 ? "" : ` (${error})`}`,
|
|
54
|
+
rejected: error === "invalid_grant",
|
|
55
|
+
status: response.status
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/bond/oauth.ts
|
|
60
|
+
const base64url = (bytes) => {
|
|
61
|
+
let binary = "";
|
|
62
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
63
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Reads OIDC discovery and requires authorization code with PKCE S256 and
|
|
67
|
+
* refresh tokens, and an issuer equal to the instance origin. Device flow is
|
|
68
|
+
* recorded when present; GitLab can disable it per instance.
|
|
69
|
+
*/
|
|
70
|
+
const discoverInstance = (origin, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
|
|
71
|
+
const canonical = yield* Effect.try({
|
|
72
|
+
try: () => canonicalOrigin(origin),
|
|
73
|
+
catch: (cause) => new ProviderError({
|
|
74
|
+
message: cause instanceof Error ? cause.message : "Invalid origin",
|
|
75
|
+
rejected: true,
|
|
76
|
+
cause
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
const response = yield* requestJson(fetchImpl, `${canonical}/.well-known/openid-configuration`, {
|
|
80
|
+
method: "GET",
|
|
81
|
+
headers: { accept: "application/json" }
|
|
82
|
+
});
|
|
83
|
+
if (response.status !== 200 || !isRecord(response.body)) return yield* Effect.fail(statusError("OIDC discovery", response));
|
|
84
|
+
const d = response.body;
|
|
85
|
+
const list = (key) => Array.isArray(d[key]) ? d[key].filter((v) => typeof v === "string") : [];
|
|
86
|
+
const problems = [];
|
|
87
|
+
if (typeof d.issuer !== "string" || d.issuer.replace(/\/$/, "").toLowerCase() !== canonical) problems.push("issuer does not match the instance origin");
|
|
88
|
+
if (!list("code_challenge_methods_supported").includes("S256")) problems.push("PKCE S256 is not supported");
|
|
89
|
+
if (!list("grant_types_supported").includes("refresh_token")) problems.push("refresh_token grant is not supported");
|
|
90
|
+
if (typeof d.authorization_endpoint !== "string" || typeof d.token_endpoint !== "string") problems.push("authorization or token endpoint is missing");
|
|
91
|
+
if (problems.length > 0) return yield* Effect.fail(new ProviderError({
|
|
92
|
+
message: `GitLab instance is not usable: ${problems.join("; ")}`,
|
|
93
|
+
rejected: true
|
|
94
|
+
}));
|
|
95
|
+
return {
|
|
96
|
+
origin: canonical,
|
|
97
|
+
deviceFlow: typeof d.device_authorization_endpoint === "string"
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
/** A fresh PKCE verifier and its S256 challenge. Keep the verifier server-side. */
|
|
101
|
+
const createPkce = () => Effect.promise(async () => {
|
|
102
|
+
const verifier = base64url(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(48)));
|
|
103
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
104
|
+
return {
|
|
105
|
+
verifier,
|
|
106
|
+
challenge: base64url(new Uint8Array(digest)),
|
|
107
|
+
method: "S256"
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
const authorizationUrl = (input) => `${canonicalOrigin(input.origin)}/oauth/authorize?${new URLSearchParams({
|
|
111
|
+
client_id: input.clientId,
|
|
112
|
+
redirect_uri: input.redirectUri,
|
|
113
|
+
response_type: "code",
|
|
114
|
+
scope: input.scopes.join(" "),
|
|
115
|
+
state: input.state,
|
|
116
|
+
code_challenge: input.codeChallenge,
|
|
117
|
+
code_challenge_method: "S256"
|
|
118
|
+
}).toString()}`;
|
|
119
|
+
const tokenGrant = (body, now) => {
|
|
120
|
+
if (!isRecord(body) || typeof body.access_token !== "string") return void 0;
|
|
121
|
+
const createdMs = typeof body.created_at === "number" ? body.created_at * 1e3 : Date.parse(now);
|
|
122
|
+
return {
|
|
123
|
+
accessToken: body.access_token,
|
|
124
|
+
...typeof body.refresh_token === "string" ? { refreshToken: body.refresh_token } : {},
|
|
125
|
+
...typeof body.expires_in === "number" ? { accessExpiresAt: new Date(createdMs + body.expires_in * 1e3).toISOString() } : {},
|
|
126
|
+
...typeof body.scope === "string" ? { scopes: body.scope.split(" ").filter(Boolean) } : {}
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
const postToken = (client, what, params) => Effect.gen(function* () {
|
|
130
|
+
const origin = canonicalOrigin(client.origin);
|
|
131
|
+
const now = (client.clock ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
|
|
132
|
+
const response = yield* requestJson(client.fetch ?? globalThis.fetch, `${origin}/oauth/token`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
136
|
+
accept: "application/json"
|
|
137
|
+
},
|
|
138
|
+
body: new URLSearchParams({
|
|
139
|
+
...params,
|
|
140
|
+
client_id: client.clientId,
|
|
141
|
+
...client.clientSecret === void 0 ? {} : { client_secret: client.clientSecret }
|
|
142
|
+
}).toString()
|
|
143
|
+
});
|
|
144
|
+
const grant = response.status === 200 ? tokenGrant(response.body, now) : void 0;
|
|
145
|
+
if (grant === void 0) return yield* Effect.fail(statusError(what, response));
|
|
146
|
+
return grant;
|
|
147
|
+
});
|
|
148
|
+
/** Exchanges an authorization code. A wrong verifier does not consume the code on GitLab. */
|
|
149
|
+
const exchangeCode = (client, input) => postToken(client, "Authorization code exchange", {
|
|
150
|
+
grant_type: "authorization_code",
|
|
151
|
+
code: input.code,
|
|
152
|
+
redirect_uri: input.redirectUri,
|
|
153
|
+
code_verifier: input.codeVerifier
|
|
154
|
+
});
|
|
155
|
+
/**
|
|
156
|
+
* Refreshes a user token. GitLab rotates both tokens and invalidates the old
|
|
157
|
+
* access token immediately, and does not detect refresh-token reuse, so the
|
|
158
|
+
* caller must hold the Bond refresh lease.
|
|
159
|
+
*/
|
|
160
|
+
const refreshAccessToken = (client, refreshToken) => postToken(client, "Token refresh", {
|
|
161
|
+
grant_type: "refresh_token",
|
|
162
|
+
refresh_token: refreshToken
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* Revokes one access or refresh token. GitLab (Doorkeeper) stores an access
|
|
166
|
+
* token and its refresh token as one record, so revoking either ends that
|
|
167
|
+
* pair only. Other tokens the same user holds for the same application keep
|
|
168
|
+
* working: on a live instance, revoking the previous token after a relink left
|
|
169
|
+
* the new one valid.
|
|
170
|
+
*/
|
|
171
|
+
const revokeToken = (client, token) => Effect.gen(function* () {
|
|
172
|
+
const origin = canonicalOrigin(client.origin);
|
|
173
|
+
const response = yield* requestJson(client.fetch ?? globalThis.fetch, `${origin}/oauth/revoke`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
176
|
+
body: new URLSearchParams({
|
|
177
|
+
token,
|
|
178
|
+
client_id: client.clientId,
|
|
179
|
+
...client.clientSecret === void 0 ? {} : { client_secret: client.clientSecret }
|
|
180
|
+
}).toString()
|
|
181
|
+
});
|
|
182
|
+
if (response.status !== 200) return yield* Effect.fail(statusError("Token revocation", response));
|
|
183
|
+
});
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/bond/tokens.ts
|
|
186
|
+
const api = (origin, path) => `${canonicalOrigin(origin)}/api/v4${path}`;
|
|
187
|
+
const readUser = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
|
|
188
|
+
const response = yield* requestJson(fetchImpl, api(origin, "/user"), {
|
|
189
|
+
method: "GET",
|
|
190
|
+
headers: {
|
|
191
|
+
...bearer(token),
|
|
192
|
+
accept: "application/json"
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const b = response.body;
|
|
196
|
+
if (response.status !== 200 || !isRecord(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading the GitLab user", response));
|
|
197
|
+
const identities = Array.isArray(b.identities) ? b.identities.flatMap((i) => isRecord(i) && typeof i.provider === "string" && typeof i.extern_uid === "string" ? [{
|
|
198
|
+
provider: i.provider,
|
|
199
|
+
externUid: i.extern_uid
|
|
200
|
+
}] : []) : [];
|
|
201
|
+
return {
|
|
202
|
+
id: String(b.id),
|
|
203
|
+
username: typeof b.username === "string" ? b.username : "",
|
|
204
|
+
name: typeof b.name === "string" ? b.name : "",
|
|
205
|
+
...typeof b.commit_email === "string" && b.commit_email.length > 0 ? { commitEmail: b.commit_email } : {},
|
|
206
|
+
bot: b.bot === true,
|
|
207
|
+
identities
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
const readTokenInfo = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
|
|
211
|
+
const response = yield* requestJson(fetchImpl, api(origin, "/personal_access_tokens/self"), {
|
|
212
|
+
method: "GET",
|
|
213
|
+
headers: {
|
|
214
|
+
...bearer(token),
|
|
215
|
+
accept: "application/json"
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
const b = response.body;
|
|
219
|
+
if (response.status !== 200 || !isRecord(b) || typeof b.id !== "number") return yield* Effect.fail(statusError("Reading token metadata", response));
|
|
220
|
+
return {
|
|
221
|
+
id: String(b.id),
|
|
222
|
+
name: typeof b.name === "string" ? b.name : "",
|
|
223
|
+
active: b.active === true,
|
|
224
|
+
revoked: b.revoked === true,
|
|
225
|
+
scopes: Array.isArray(b.scopes) ? b.scopes.filter((s) => typeof s === "string") : [],
|
|
226
|
+
...typeof b.expires_at === "string" ? { expiresAt: b.expires_at } : {},
|
|
227
|
+
userId: String(b.user_id)
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
/** `DELETE /api/v4/personal_access_tokens/self`: a token revokes itself. */
|
|
231
|
+
const revokeSelfToken = (origin, token, fetchImpl = globalThis.fetch) => Effect.gen(function* () {
|
|
232
|
+
const response = yield* requestJson(fetchImpl, api(origin, "/personal_access_tokens/self"), {
|
|
233
|
+
method: "DELETE",
|
|
234
|
+
headers: bearer(token)
|
|
235
|
+
});
|
|
236
|
+
if (response.status !== 204 && response.status !== 200) return yield* Effect.fail(statusError("Token self-revocation", response));
|
|
237
|
+
});
|
|
238
|
+
/** End of the UTC expiry day, which is when GitLab stops accepting the token. */
|
|
239
|
+
const tokenExpiryInstant = (expiresAt) => (/* @__PURE__ */ new Date(`${expiresAt}T23:59:59.999Z`)).toISOString();
|
|
240
|
+
const commonProblems = (info, user, policy) => {
|
|
241
|
+
const problems = [];
|
|
242
|
+
if (info.userId !== user.id) problems.push("user_mismatch");
|
|
243
|
+
if (info.revoked) problems.push("revoked");
|
|
244
|
+
if (!info.active) problems.push("inactive");
|
|
245
|
+
if (info.scopes.some((s) => !policy.allowedScopes.includes(s))) problems.push("scope_not_allowed");
|
|
246
|
+
if (info.expiresAt === void 0) problems.push("no_expiry");
|
|
247
|
+
else {
|
|
248
|
+
const end = Date.parse(tokenExpiryInstant(info.expiresAt));
|
|
249
|
+
const now = Date.parse(policy.now);
|
|
250
|
+
if (end <= now) problems.push("expired");
|
|
251
|
+
else if (end - now > (policy.maxLifetimeDays + 1) * 24 * 60 * 6e4) problems.push("lifetime_exceeded");
|
|
252
|
+
}
|
|
253
|
+
return problems;
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Checks a pasted personal access token: active, scopes within the allowed
|
|
257
|
+
* set, an expiry within the maximum lifetime, and a human owner.
|
|
258
|
+
*/
|
|
259
|
+
const validatePersonalAccessToken = (info, user, policy) => [...commonProblems(info, user, policy), ...user.bot ? ["is_a_bot"] : []];
|
|
260
|
+
/**
|
|
261
|
+
* Checks a bot credential registered as an Installation: the same token
|
|
262
|
+
* checks, plus `bot: true` on its user. A human account's token is never
|
|
263
|
+
* accepted as an app principal.
|
|
264
|
+
*/
|
|
265
|
+
const validateBotCredential = (info, user, policy) => [...commonProblems(info, user, policy), ...user.bot ? [] : ["not_a_bot"]];
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/bond/classify.ts
|
|
268
|
+
const deny = (reason) => ({
|
|
269
|
+
allow: false,
|
|
270
|
+
reason
|
|
271
|
+
});
|
|
272
|
+
const normalizePath = (raw) => {
|
|
273
|
+
let decoded;
|
|
274
|
+
try {
|
|
275
|
+
decoded = decodeURIComponent(raw);
|
|
276
|
+
} catch {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const path = decoded.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
280
|
+
const segments = path.split("/");
|
|
281
|
+
if (segments.length < 2 || segments.some((s) => s === "" || s === "." || s === ".." || !/^[\w.-]+$/.test(s))) return;
|
|
282
|
+
return path;
|
|
283
|
+
};
|
|
284
|
+
const projectRefOf = (raw) => /^\d+$/.test(raw) ? {
|
|
285
|
+
kind: "id",
|
|
286
|
+
id: Number(raw)
|
|
287
|
+
} : (() => {
|
|
288
|
+
const path = normalizePath(raw);
|
|
289
|
+
return path === void 0 ? void 0 : {
|
|
290
|
+
kind: "path",
|
|
291
|
+
path
|
|
292
|
+
};
|
|
293
|
+
})();
|
|
294
|
+
/**
|
|
295
|
+
* REST reads, by exact shape. A branch name may carry encoded slashes
|
|
296
|
+
* (`feature%2Fx`), and nothing else is percent-encoded.
|
|
297
|
+
*/
|
|
298
|
+
const READ_TAILS = [
|
|
299
|
+
/^$/,
|
|
300
|
+
/^\/merge_requests$/,
|
|
301
|
+
/^\/merge_requests\/\d+(\/(notes|commits|changes|diffs|approvals))?$/,
|
|
302
|
+
/^\/repository\/branches$/,
|
|
303
|
+
/^\/repository\/branches\/([\w.-]|%2[fF])+$/,
|
|
304
|
+
/^\/repository\/commits$/,
|
|
305
|
+
/^\/repository\/commits\/[0-9a-f]{7,64}$/
|
|
306
|
+
];
|
|
307
|
+
const GIT_ROUTE = /^\/(.+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/i;
|
|
308
|
+
/**
|
|
309
|
+
* Classifies one request in GitLab's own URL shape. Allows git smart HTTP
|
|
310
|
+
* fetch and push, REST reads of a project, merge request creation, and
|
|
311
|
+
* merge request notes. Denies GraphQL, LFS, archives, raw files, and every
|
|
312
|
+
* other route.
|
|
313
|
+
*/
|
|
314
|
+
const classifyGitLabRequest = (method, url) => {
|
|
315
|
+
const u = typeof url === "string" ? new URL(url, "https://gitlab.invalid") : url;
|
|
316
|
+
const verb = method.toUpperCase();
|
|
317
|
+
const path = u.pathname;
|
|
318
|
+
if (path.includes("/../") || path.endsWith("/..")) return deny("path traversal");
|
|
319
|
+
if (/%2e|%5c/i.test(path)) return deny("encoded traversal characters");
|
|
320
|
+
if (/^\/api\/graphql\/?$/i.test(path)) return deny("GraphQL is not proxied");
|
|
321
|
+
if (/\/info\/lfs(\/|$)/i.test(path)) return deny("LFS is not proxied");
|
|
322
|
+
const git = path.match(GIT_ROUTE);
|
|
323
|
+
if (git !== null && !/^\/api\//i.test(path)) {
|
|
324
|
+
const [, rawProject, endpoint] = git;
|
|
325
|
+
const ref = rawProject === void 0 ? void 0 : normalizePath(rawProject);
|
|
326
|
+
if (ref === void 0) return deny("invalid project path");
|
|
327
|
+
const service = endpoint?.toLowerCase() === "info/refs" ? u.searchParams.get("service") : endpoint?.toLowerCase();
|
|
328
|
+
const operation = service === "git-upload-pack" ? "fetch" : service === "git-receive-pack" ? "push" : void 0;
|
|
329
|
+
if (operation === void 0) return deny("git service not allowed");
|
|
330
|
+
if (verb !== (endpoint?.toLowerCase() === "info/refs" ? "GET" : "POST")) return deny(`method ${verb} not allowed`);
|
|
331
|
+
return {
|
|
332
|
+
allow: true,
|
|
333
|
+
projectRef: {
|
|
334
|
+
kind: "path",
|
|
335
|
+
path: ref
|
|
336
|
+
},
|
|
337
|
+
operation,
|
|
338
|
+
surface: "git"
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const rest = path.match(/^\/api\/v4\/projects\/([^/]+)(\/.*)?$/i);
|
|
342
|
+
if (rest !== null) {
|
|
343
|
+
const [, rawProject, rest2] = rest;
|
|
344
|
+
const projectRef = rawProject === void 0 ? void 0 : projectRefOf(rawProject);
|
|
345
|
+
if (projectRef === void 0) return deny("invalid project reference");
|
|
346
|
+
const tail = (rest2 ?? "").replace(/\/$/, "");
|
|
347
|
+
if (tail.includes("..")) return deny("path traversal");
|
|
348
|
+
const allow = (operation) => ({
|
|
349
|
+
allow: true,
|
|
350
|
+
projectRef,
|
|
351
|
+
operation,
|
|
352
|
+
surface: "rest",
|
|
353
|
+
restTail: tail
|
|
354
|
+
});
|
|
355
|
+
if (verb === "GET" && READ_TAILS.some((re) => re.test(tail))) return allow("read");
|
|
356
|
+
if (verb === "POST") {
|
|
357
|
+
if (u.search !== "") return deny("query parameters on a write");
|
|
358
|
+
if (tail === "/merge_requests") return allow("open_mr");
|
|
359
|
+
if (/^\/merge_requests\/\d+\/notes$/.test(tail)) return allow("comment_mr");
|
|
360
|
+
}
|
|
361
|
+
return deny("REST route not allowed");
|
|
362
|
+
}
|
|
363
|
+
return deny("route not allowed");
|
|
364
|
+
};
|
|
365
|
+
/**
|
|
366
|
+
* GitLab lets `POST /projects/:id/merge_requests` name another project in
|
|
367
|
+
* `target_project_id`, so the URL alone does not bound where the merge
|
|
368
|
+
* request opens. Returns the reason to deny, or undefined when the body
|
|
369
|
+
* targets the classified project.
|
|
370
|
+
*
|
|
371
|
+
* Pass the parsed JSON object or form body and the numeric ID the URL's
|
|
372
|
+
* project reference resolved to, then forward the body you checked
|
|
373
|
+
* (re-serialized), never the raw bytes: parsers disagree on repeated keys.
|
|
374
|
+
* For the same reason a form that repeats `target_project_id`, or uses a
|
|
375
|
+
* variant such as `target_project_id[]`, is denied.
|
|
376
|
+
*/
|
|
377
|
+
const mergeRequestBodyProblem = (body, resolvedProjectId) => {
|
|
378
|
+
let target;
|
|
379
|
+
if (body instanceof URLSearchParams) {
|
|
380
|
+
const keys = [...body.keys()].filter((k) => k.startsWith("target_project_id"));
|
|
381
|
+
if (keys.some((k) => k !== "target_project_id")) return "target_project_id has an unsupported form";
|
|
382
|
+
if (keys.length > 1) return "target_project_id appears more than once";
|
|
383
|
+
target = body.get("target_project_id") ?? void 0;
|
|
384
|
+
} else if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
|
385
|
+
const record = body;
|
|
386
|
+
if (Object.keys(record).some((k) => k.startsWith("target_project_id") && k !== "target_project_id")) return "target_project_id has an unsupported form";
|
|
387
|
+
target = record.target_project_id;
|
|
388
|
+
} else if (body === void 0 || body === null) target = void 0;
|
|
389
|
+
else return "merge request body is not an object";
|
|
390
|
+
if (target === void 0 || target === "") return void 0;
|
|
391
|
+
const id = typeof target === "number" ? target : typeof target === "string" && /^\d+$/.test(target) ? Number(target) : NaN;
|
|
392
|
+
if (!Number.isSafeInteger(id)) return "target_project_id is not a project ID";
|
|
393
|
+
return id === resolvedProjectId ? void 0 : "target_project_id names another project";
|
|
394
|
+
};
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/bond/receive-pack.ts
|
|
397
|
+
const ZERO_OID = "0".repeat(40);
|
|
398
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
399
|
+
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
400
|
+
/**
|
|
401
|
+
* A strict subset of `git check-ref-format`: a full `refs/` name with no
|
|
402
|
+
* control characters, no characters git forbids, no empty, dot-leading, or
|
|
403
|
+
* `.lock` components, no `..`, and no `@{`.
|
|
404
|
+
*/
|
|
405
|
+
const isValidRefName = (ref) => {
|
|
406
|
+
if (!ref.startsWith("refs/") || ref.length > 1024) return false;
|
|
407
|
+
if (/[\x00-\x20\x7f~^:?*[\\]/.test(ref)) return false;
|
|
408
|
+
if (ref.includes("..") || ref.includes("@{") || ref.endsWith(".")) return false;
|
|
409
|
+
return ref.split("/").every((c) => c !== "" && !c.startsWith(".") && !c.endsWith(".lock"));
|
|
410
|
+
};
|
|
411
|
+
const concat = (a, b) => {
|
|
412
|
+
if (a.length === 0) return b;
|
|
413
|
+
const out = new Uint8Array(a.length + b.length);
|
|
414
|
+
out.set(a, 0);
|
|
415
|
+
out.set(b, a.length);
|
|
416
|
+
return out;
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Incremental receive-pack head parser. Each byte is examined once, and only
|
|
420
|
+
* an unfinished pkt-line is buffered, so a head fed in many small chunks
|
|
421
|
+
* costs linear time. Capabilities are the union over every command line, as
|
|
422
|
+
* in git's `read_head`: a client cannot hide `push-options` behind a later
|
|
423
|
+
* line without them.
|
|
424
|
+
*/
|
|
425
|
+
const makeReceivePackHeadParser = (limits = {}) => {
|
|
426
|
+
const maxHeadBytes = limits.maxHeadBytes ?? 1048576;
|
|
427
|
+
const maxCommands = limits.maxCommands ?? 1e3;
|
|
428
|
+
const maxOptions = limits.maxOptions ?? 100;
|
|
429
|
+
let pending = /* @__PURE__ */ new Uint8Array(0);
|
|
430
|
+
let consumed = 0;
|
|
431
|
+
let phase = "commands";
|
|
432
|
+
const commands = [];
|
|
433
|
+
const capabilities = /* @__PURE__ */ new Set();
|
|
434
|
+
const options = [];
|
|
435
|
+
let result = { _tag: "Incomplete" };
|
|
436
|
+
const malformed = (reason) => {
|
|
437
|
+
result = {
|
|
438
|
+
_tag: "Malformed",
|
|
439
|
+
reason
|
|
440
|
+
};
|
|
441
|
+
phase = "done";
|
|
442
|
+
return result;
|
|
443
|
+
};
|
|
444
|
+
const complete = () => {
|
|
445
|
+
phase = "done";
|
|
446
|
+
result = {
|
|
447
|
+
_tag: "Complete",
|
|
448
|
+
head: {
|
|
449
|
+
commands,
|
|
450
|
+
capabilities: [...capabilities],
|
|
451
|
+
options,
|
|
452
|
+
headLength: consumed
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
return result;
|
|
456
|
+
};
|
|
457
|
+
/** One pkt-line: text, null for a flush, undefined when more bytes are needed. */
|
|
458
|
+
const nextPkt = () => {
|
|
459
|
+
if (pending.length < 4) return void 0;
|
|
460
|
+
let lenText;
|
|
461
|
+
try {
|
|
462
|
+
lenText = decoder.decode(pending.subarray(0, 4));
|
|
463
|
+
} catch {
|
|
464
|
+
return malformed("bad pkt-line length");
|
|
465
|
+
}
|
|
466
|
+
if (!/^[0-9a-f]{4}$/i.test(lenText)) return malformed("bad pkt-line length");
|
|
467
|
+
const len = Number.parseInt(lenText, 16);
|
|
468
|
+
if (len === 0) {
|
|
469
|
+
pending = pending.subarray(4);
|
|
470
|
+
consumed += 4;
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
if (len < 4) return malformed("bad pkt-line length");
|
|
474
|
+
if (pending.length < len) return void 0;
|
|
475
|
+
let line;
|
|
476
|
+
try {
|
|
477
|
+
line = decoder.decode(pending.subarray(4, len));
|
|
478
|
+
} catch {
|
|
479
|
+
return malformed("pkt-line is not valid UTF-8");
|
|
480
|
+
}
|
|
481
|
+
pending = pending.subarray(len);
|
|
482
|
+
consumed += len;
|
|
483
|
+
return line.endsWith("\n") ? line.slice(0, -1) : line;
|
|
484
|
+
};
|
|
485
|
+
const step = () => {
|
|
486
|
+
for (;;) {
|
|
487
|
+
const pkt = nextPkt();
|
|
488
|
+
if (pkt === void 0) return consumed + pending.length > maxHeadBytes ? malformed("receive-pack head is too large") : result;
|
|
489
|
+
if (typeof pkt === "object" && pkt !== null) return pkt;
|
|
490
|
+
if (phase === "commands") {
|
|
491
|
+
if (pkt === null) {
|
|
492
|
+
if (commands.length === 0) return malformed("no receive-pack commands");
|
|
493
|
+
if (!capabilities.has("push-options")) return complete();
|
|
494
|
+
phase = "options";
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (pkt.startsWith("shallow ")) continue;
|
|
498
|
+
const nul = pkt.indexOf("\0");
|
|
499
|
+
const command = nul === -1 ? pkt : pkt.slice(0, nul);
|
|
500
|
+
if (nul !== -1) {
|
|
501
|
+
for (const cap of pkt.slice(nul + 1).split(" ")) if (cap !== "") capabilities.add(cap);
|
|
502
|
+
}
|
|
503
|
+
const [oldId, newId, ref, extra] = command.split(" ");
|
|
504
|
+
if (oldId === void 0 || newId === void 0 || ref === void 0 || extra !== void 0 || !OID.test(oldId) || !OID.test(newId)) return malformed("bad receive-pack command");
|
|
505
|
+
if (!isValidRefName(ref)) return malformed("invalid ref name");
|
|
506
|
+
if (commands.length >= maxCommands) return malformed("too many receive-pack commands");
|
|
507
|
+
commands.push({
|
|
508
|
+
oldId,
|
|
509
|
+
newId,
|
|
510
|
+
ref
|
|
511
|
+
});
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (pkt === null) return complete();
|
|
515
|
+
if (options.length >= maxOptions) return malformed("too many push options");
|
|
516
|
+
options.push(pkt);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
return {
|
|
520
|
+
push: (chunk) => {
|
|
521
|
+
if (phase === "done") return result;
|
|
522
|
+
pending = concat(pending, chunk);
|
|
523
|
+
return step();
|
|
524
|
+
},
|
|
525
|
+
remainder: () => phase === "done" ? pending : /* @__PURE__ */ new Uint8Array(0)
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
/**
|
|
529
|
+
* Parses as much of the receive-pack head as `bytes` holds. A convenience
|
|
530
|
+
* for a buffered prefix; streaming proxies should use
|
|
531
|
+
* `makeReceivePackHeadParser` and feed chunks as they arrive.
|
|
532
|
+
*/
|
|
533
|
+
const parseReceivePackHead = (bytes, limits) => makeReceivePackHeadParser(limits).push(bytes);
|
|
534
|
+
const MERGE_REQUEST_OPTIONS = [
|
|
535
|
+
/^merge_request\.create$/,
|
|
536
|
+
/^merge_request\.target=[\w./-]{1,255}$/,
|
|
537
|
+
/^merge_request\.title=.{1,255}$/s,
|
|
538
|
+
/^merge_request\.description=.{0,10000}$/s,
|
|
539
|
+
/^merge_request\.label=[\w.:-]{1,100}$/,
|
|
540
|
+
/^merge_request\.remove_source_branch$/,
|
|
541
|
+
/^merge_request\.draft$/
|
|
542
|
+
];
|
|
543
|
+
/**
|
|
544
|
+
* Returns the reason a push is denied, or undefined when it is allowed.
|
|
545
|
+
* Denies tags and other non-branch refs, deletions, the default branch, and
|
|
546
|
+
* any push option outside the merge request allow-list. GitLab still applies
|
|
547
|
+
* its own branch protection afterwards.
|
|
548
|
+
*/
|
|
549
|
+
const evaluatePush = (head, policy) => {
|
|
550
|
+
for (const c of head.commands) {
|
|
551
|
+
if (!c.ref.startsWith("refs/heads/")) return `ref ${c.ref} is not a branch`;
|
|
552
|
+
if (/^0+$/.test(c.newId)) return `deleting ${c.ref} is not allowed`;
|
|
553
|
+
const branch = c.ref.slice(11);
|
|
554
|
+
if (branch === policy.defaultBranch && policy.allowDefaultBranch !== true) return "pushing to the default branch is not allowed";
|
|
555
|
+
if (policy.branchPrefix !== void 0 && !branch.startsWith(policy.branchPrefix)) return `branch ${branch} is outside ${policy.branchPrefix}`;
|
|
556
|
+
}
|
|
557
|
+
for (const option of head.options) {
|
|
558
|
+
const name = option.split("=")[0] ?? option;
|
|
559
|
+
if (!policy.allowMergeRequestOptions || !MERGE_REQUEST_OPTIONS.some((re) => re.test(option))) return `push option ${name} is not allowed`;
|
|
560
|
+
}
|
|
561
|
+
if (head.options.includes("merge_request.create") && !head.options.includes(`merge_request.label=${policy.requiredMergeRequestLabel}`)) return `merge requests must carry merge_request.label=${policy.requiredMergeRequestLabel}`;
|
|
562
|
+
};
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/bond/attribution.ts
|
|
565
|
+
/** The commit author for a GitLab user: their name and `commit_email`. */
|
|
566
|
+
const commitAuthor = (user) => {
|
|
567
|
+
if (user.commitEmail === void 0) throw new Error(`GitLab user ${user.username} has no commit email`);
|
|
568
|
+
return {
|
|
569
|
+
name: user.name || user.username,
|
|
570
|
+
email: user.commitEmail
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
const oneLine = (value) => value.replace(/[\r\n]+/g, " ").trim();
|
|
574
|
+
/** `Assisted-by: <app> <session URL>`, added to every commit. */
|
|
575
|
+
const assistedByTrailer = (appName, sessionUrl) => `Assisted-by: ${oneLine(appName)} <${oneLine(sessionUrl)}>`;
|
|
576
|
+
/** `Co-authored-by` for a participant or, with the app principal, the requester. */
|
|
577
|
+
const coAuthoredByTrailer = (who) => `Co-authored-by: ${oneLine(who.name)} <${oneLine(who.email)}>`;
|
|
578
|
+
/** Description footer for merge requests and comments. */
|
|
579
|
+
const mergeRequestFooter = (input) => ["---", input.requestedBy === void 0 ? `Opened through ${oneLine(input.appName)}: ${oneLine(input.sessionUrl)}` : `Requested by @${oneLine(input.requestedBy)} via ${oneLine(input.appName)}: ${oneLine(input.sessionUrl)}`].join("\n");
|
|
580
|
+
/** `git push -o` options that create a labelled merge request. */
|
|
581
|
+
const mergeRequestPushOptions = (input) => [
|
|
582
|
+
"merge_request.create",
|
|
583
|
+
`merge_request.target=${input.target}`,
|
|
584
|
+
`merge_request.title=${oneLine(input.title)}`,
|
|
585
|
+
`merge_request.description=${oneLine(input.description)}`,
|
|
586
|
+
`merge_request.label=${input.label}`
|
|
587
|
+
];
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/bond/provider.ts
|
|
590
|
+
/**
|
|
591
|
+
* Bond's GitLab provider. A configured `oauth.clientSecret` requires
|
|
592
|
+
* `resolveSecret`; the provider never silently degrades to a public client. GitLab has no delegation marker, so user-principal
|
|
593
|
+
* writes need the ProviderApp's `allowUserPrincipalOutput`. The app principal
|
|
594
|
+
* is a registered bot credential stored on the Installation; GitLab 17.1
|
|
595
|
+
* cannot rotate it from the bot itself, so an administrator replaces it.
|
|
596
|
+
*/
|
|
597
|
+
const makeGitLabBondProvider = (options = {}) => {
|
|
598
|
+
const client = (config) => Effect.gen(function* () {
|
|
599
|
+
if (config.oauth === void 0) return yield* Effect.fail(new ProviderError({
|
|
600
|
+
message: `${config.id} has no OAuth application`,
|
|
601
|
+
rejected: false
|
|
602
|
+
}));
|
|
603
|
+
const secretRef = config.oauth.clientSecret;
|
|
604
|
+
if (secretRef !== void 0 && options.resolveSecret === void 0) return yield* Effect.fail(new ProviderError({
|
|
605
|
+
message: `${config.id} has an OAuth client secret but the provider has no resolveSecret`,
|
|
606
|
+
rejected: false
|
|
607
|
+
}));
|
|
608
|
+
const clientSecret = secretRef === void 0 || options.resolveSecret === void 0 ? void 0 : yield* options.resolveSecret(secretRef).pipe(Effect.mapError((cause) => new ProviderError({
|
|
609
|
+
message: "Unable to resolve the OAuth client secret",
|
|
610
|
+
rejected: false,
|
|
611
|
+
cause
|
|
612
|
+
})));
|
|
613
|
+
return {
|
|
614
|
+
origin: config.baseUrl,
|
|
615
|
+
clientId: config.oauth.clientId,
|
|
616
|
+
...clientSecret === void 0 ? {} : { clientSecret },
|
|
617
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
618
|
+
...options.clock === void 0 ? {} : { clock: options.clock }
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
return {
|
|
622
|
+
provider: "gitlab",
|
|
623
|
+
delegationMarker: false,
|
|
624
|
+
refreshUserToken: (config, refreshToken) => client(config).pipe(Effect.flatMap((c) => refreshAccessToken(c, refreshToken))),
|
|
625
|
+
revokeUserToken: (config, tokens) => tokens.method === "pat" ? revokeSelfToken(config.baseUrl, tokens.accessToken, options.fetch) : client(config).pipe(Effect.flatMap((c) => revokeToken(c, tokens.accessToken))),
|
|
626
|
+
appCredential: (config, installation, stored) => stored === void 0 ? Effect.fail(new ProviderError({
|
|
627
|
+
message: `Installation ${installation.id} of ${config.id} has no bot credential`,
|
|
628
|
+
rejected: true
|
|
629
|
+
})) : Effect.succeed({
|
|
630
|
+
token: stored,
|
|
631
|
+
...installation.credentialExpiresAt === void 0 ? {} : { expiresAt: installation.credentialExpiresAt }
|
|
632
|
+
})
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
//#endregion
|
|
636
|
+
export { ZERO_OID, assistedByTrailer, authorizationUrl, canonicalOrigin, classifyGitLabRequest, coAuthoredByTrailer, commitAuthor, createPkce, discoverInstance, evaluatePush, exchangeCode, isValidRefName, makeGitLabBondProvider, makeReceivePackHeadParser, mergeRequestBodyProblem, mergeRequestFooter, mergeRequestPushOptions, parseReceivePackHead, readTokenInfo, readUser, refreshAccessToken, revokeSelfToken, revokeToken, tokenExpiryInstant, validateBotCredential, validatePersonalAccessToken };
|
|
637
|
+
|
|
638
|
+
//# sourceMappingURL=index.js.map
|