@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.
@@ -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