engineering-memory 1.11.27 → 1.11.29

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,226 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import * as z from 'zod/v4';
4
+ import { copies, escapeHtml, format, textDirection } from '../runtime/texts.js';
5
+ import { GitLabError, gitLabAddress } from './gitlab.js';
6
+ const text = z.string().min(1);
7
+ const pageWording = z.strictObject({
8
+ title: text,
9
+ intro: text,
10
+ address: text,
11
+ addressHint: text,
12
+ token: text,
13
+ tokenHint: text,
14
+ save: text,
15
+ remove: text,
16
+ saved: text,
17
+ savedReadOnly: text,
18
+ removed: text,
19
+ rejected: text,
20
+ noApiScope: text,
21
+ unreachable: text,
22
+ certificate: text,
23
+ redirected: text,
24
+ notGitLab: text,
25
+ invalidAddress: text,
26
+ missingToken: text,
27
+ gitlabError: text,
28
+ failed: text,
29
+ expired: text,
30
+ });
31
+ const maximumBody = 8 * 1024;
32
+ export class GitLabTokenPage {
33
+ tokens;
34
+ gitlab;
35
+ language;
36
+ server = null;
37
+ page = null;
38
+ timer = null;
39
+ queue = Promise.resolve();
40
+ constructor(tokens, gitlab, language) {
41
+ this.tokens = tokens;
42
+ this.gitlab = gitlab;
43
+ this.language = language;
44
+ }
45
+ async open(target) {
46
+ await this.close();
47
+ const server = createServer((request, response) => {
48
+ this.queue = this.queue.then(() => this.handle(request, response)).catch(() => undefined);
49
+ });
50
+ await new Promise((resolvePromise, reject) => {
51
+ server.once('error', reject);
52
+ server.listen(0, '127.0.0.1', resolvePromise);
53
+ });
54
+ server.unref();
55
+ const port = server.address().port;
56
+ const expiresAt = Date.now() + 10 * 60 * 1000;
57
+ this.server = server;
58
+ this.page = { nonce: randomBytes(32).toString('base64url'), port, ...target, expiresAt };
59
+ this.timer = setTimeout(() => void this.close(), expiresAt - Date.now());
60
+ this.timer.unref();
61
+ return {
62
+ url: `http://127.0.0.1:${port}/${this.page.nonce}`,
63
+ expiresAt: new Date(expiresAt).toISOString(),
64
+ };
65
+ }
66
+ async close() {
67
+ const server = this.server;
68
+ this.server = null;
69
+ this.page = null;
70
+ if (this.timer)
71
+ clearTimeout(this.timer);
72
+ this.timer = null;
73
+ if (!server)
74
+ return;
75
+ await new Promise((resolvePromise) => {
76
+ server.close(() => resolvePromise());
77
+ server.closeAllConnections();
78
+ });
79
+ }
80
+ async handle(request, response) {
81
+ const page = this.page;
82
+ try {
83
+ const own = page ? `127.0.0.1:${page.port}` : null;
84
+ if (!page || request.headers.host !== own)
85
+ return this.send(response, 421, await this.notice('expired'));
86
+ if (request.url !== `/${page.nonce}`)
87
+ return this.send(response, 404, await this.notice('expired'));
88
+ if (Date.now() >= page.expiresAt)
89
+ return this.finish(response, await this.notice('expired'), 410);
90
+ if (request.method === 'GET')
91
+ return this.send(response, 200, await this.form(page, page.address ?? ''));
92
+ if (request.method !== 'POST')
93
+ return this.send(response, 405, '', { Allow: 'GET, POST' });
94
+ if (request.headers.origin !== undefined && request.headers.origin !== `http://${own}`)
95
+ return this.send(response, 403, await this.notice('expired'));
96
+ if (!/^application\/x-www-form-urlencoded(;|$)/i.test(request.headers['content-type'] ?? ''))
97
+ return this.send(response, 415, await this.notice('failed'));
98
+ const body = await readBody(request);
99
+ if (body === null)
100
+ return this.send(response, 413, await this.notice('failed'));
101
+ const fields = new URLSearchParams(body);
102
+ const typed = fields.get('address') ?? '';
103
+ const address = gitLabAddress(typed);
104
+ if (!address)
105
+ return this.send(response, 400, await this.form(page, typed, 'invalidAddress'));
106
+ if (fields.get('action') === 'remove') {
107
+ await this.tokens.remove(address);
108
+ return this.finish(response, await this.notice('removed'));
109
+ }
110
+ const token = (fields.get('token') ?? '').trim();
111
+ if (!token || token.length > 1024 || /[\s\u0000-\u001f\u007f]/.test(token))
112
+ return this.send(response, 400, await this.form(page, address, 'missingToken'));
113
+ try {
114
+ await this.gitlab.user(address, token);
115
+ const scopes = await this.gitlab.scopes(address, token);
116
+ await this.tokens.save(page.host ?? new URL(address).host, address, token);
117
+ return this.finish(response, await this.notice(scopes && !scopes.includes('api') ? 'savedReadOnly' : 'saved'));
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof GitLabError))
121
+ throw error;
122
+ return this.send(response, 400, await this.form(page, address, refusal(error), error));
123
+ }
124
+ }
125
+ catch {
126
+ return this.send(response, 500, await this.notice('failed'));
127
+ }
128
+ }
129
+ async finish(response, html, status = 200) {
130
+ this.page = null;
131
+ const server = this.server;
132
+ response.once('finish', () => {
133
+ if (this.server === server)
134
+ void this.close();
135
+ });
136
+ this.send(response, status, html);
137
+ }
138
+ send(response, status, html, headers = {}) {
139
+ response.writeHead(status, {
140
+ 'Content-Type': 'text/html; charset=utf-8',
141
+ 'Cache-Control': 'no-store',
142
+ 'Referrer-Policy': 'no-referrer',
143
+ 'X-Content-Type-Options': 'nosniff',
144
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
145
+ ...headers,
146
+ });
147
+ response.end(html);
148
+ }
149
+ async wording() {
150
+ const told = await this.language().catch(() => undefined);
151
+ return copies(pageWording, 'gitlabToken', told)[0];
152
+ }
153
+ async notice(key) {
154
+ const { language, copy } = await this.wording();
155
+ return document(language, copy.title, `<p>${escapeHtml(copy[key])}</p>`);
156
+ }
157
+ async form(page, address, problem, error) {
158
+ const { language, copy } = await this.wording();
159
+ const shown = problem
160
+ ? format(copy[problem], language, {
161
+ address: error?.address ?? address,
162
+ status: error?.status ?? '-',
163
+ })
164
+ : null;
165
+ return document(language, copy.title, `<p>${escapeHtml(copy.intro)}</p>` +
166
+ (shown ? `<p class="problem" role="alert">${escapeHtml(shown)}</p>` : '') +
167
+ `<form method="post" action="/${page.nonce}" autocomplete="off">` +
168
+ `<label for="address">${escapeHtml(copy.address)}</label>` +
169
+ `<input id="address" name="address" type="url" required value="${escapeHtml(address)}">` +
170
+ `<p class="hint">${escapeHtml(copy.addressHint)}</p>` +
171
+ `<label for="token">${escapeHtml(copy.token)}</label>` +
172
+ `<input id="token" name="token" type="password" autocomplete="new-password" spellcheck="false">` +
173
+ `<p class="hint">${escapeHtml(copy.tokenHint)}</p>` +
174
+ `<div class="actions"><button name="action" value="save">${escapeHtml(copy.save)}</button>` +
175
+ `<button name="action" value="remove" class="secondary" formnovalidate>${escapeHtml(copy.remove)}</button></div>` +
176
+ `</form>`);
177
+ }
178
+ }
179
+ function refusal(error) {
180
+ switch (error.failure) {
181
+ case 'token_rejected':
182
+ return 'rejected';
183
+ case 'not_allowed':
184
+ return 'noApiScope';
185
+ case 'unreachable':
186
+ return 'unreachable';
187
+ case 'untrusted_certificate':
188
+ return 'certificate';
189
+ case 'redirected':
190
+ return 'redirected';
191
+ case 'not_found':
192
+ case 'unexpected_answer':
193
+ return 'notGitLab';
194
+ default:
195
+ return 'gitlabError';
196
+ }
197
+ }
198
+ function document(language, title, body) {
199
+ return (`<!doctype html><html lang="${escapeHtml(language)}" dir="${textDirection(language)}"><head>` +
200
+ '<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
201
+ `<title>${escapeHtml(title)}</title><style>` +
202
+ 'body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:24px 16px;background:#f6f7f9;color:#1d2330}' +
203
+ 'main{max-width:560px;margin:0 auto}h1{font-size:22px;line-height:1.3}' +
204
+ 'label{display:block;font-weight:600;margin-top:20px}' +
205
+ 'input{box-sizing:border-box;width:100%;padding:10px;margin-top:6px;font:inherit;border:1px solid #9aa3b2;border-radius:6px;background:#fff;color:inherit}' +
206
+ '.hint{margin:6px 0 0;font-size:14px;color:#4b5565}.problem{padding:10px 12px;border-radius:6px;background:#fdecec;color:#8a1c1c}' +
207
+ '.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}' +
208
+ 'button{font:inherit;padding:10px 16px;border-radius:6px;border:1px solid #2952cc;background:#2952cc;color:#fff;cursor:pointer}' +
209
+ 'button.secondary{background:transparent;color:#2952cc}' +
210
+ '@media (prefers-color-scheme:dark){body{background:#14171c;color:#e6e9ef}input{background:#1d2129;border-color:#4b5565}.hint{color:#aab2c0}.problem{background:#3a1d1d;color:#f3b4b4}button{background:#5b7ef0;border-color:#5b7ef0}button.secondary{color:#9db3ff}}' +
211
+ `</style></head><body><main><h1>${escapeHtml(title)}</h1>${body}</main></body></html>`);
212
+ }
213
+ function readBody(request) {
214
+ return new Promise((resolvePromise, reject) => {
215
+ const chunks = [];
216
+ let size = 0;
217
+ request.on('data', (chunk) => {
218
+ size += chunk.length;
219
+ if (size <= maximumBody)
220
+ chunks.push(chunk);
221
+ });
222
+ request.once('end', () => resolvePromise(size > maximumBody ? null : Buffer.concat(chunks).toString('utf8')));
223
+ request.once('error', reject);
224
+ });
225
+ }
226
+ //# sourceMappingURL=gitlab-token-page.js.map
@@ -0,0 +1,421 @@
1
+ import { isIP } from 'node:net';
2
+ import { join } from 'node:path';
3
+ import * as z from 'zod/v4';
4
+ import { principalFingerprint } from '../runtime/principal-state.js';
5
+ import { readJson, removeFile, writeJson } from '../utilities/files.js';
6
+ import { sha256 } from '../utilities/hash.js';
7
+ export function gitLabRemote(remote) {
8
+ const trimmed = remote.trim();
9
+ const scpLike = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(trimmed);
10
+ let scheme = 'ssh';
11
+ let hostname;
12
+ let port = '';
13
+ let path;
14
+ if (scpLike &&
15
+ !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) &&
16
+ !/^[A-Za-z]:[\\/]/.test(trimmed)) {
17
+ hostname = scpLike[1].toLowerCase();
18
+ path = scpLike[2];
19
+ }
20
+ else if (URL.canParse(trimmed)) {
21
+ const url = new URL(trimmed);
22
+ scheme = url.protocol.slice(0, -1).toLowerCase();
23
+ hostname = url.hostname.toLowerCase();
24
+ port = url.port;
25
+ path = url.pathname;
26
+ }
27
+ else
28
+ return { refusal: 'local' };
29
+ if (!hostname || scheme === 'file')
30
+ return { refusal: 'local' };
31
+ if (isIP(hostname.replace(/^\[|\]$/g, '')))
32
+ return { refusal: 'ip' };
33
+ if (hostname === 'github.com' || hostname.endsWith('.github.com'))
34
+ return { refusal: 'github' };
35
+ const segments = path
36
+ .replace(/^\/+|\/+$/g, '')
37
+ .replace(/\.git$/i, '')
38
+ .split('/');
39
+ if (segments.length < 2 || !segments.every(pathSegment))
40
+ return { refusal: 'unsupported' };
41
+ if (scheme === 'https' || scheme === 'http') {
42
+ const host = port ? `${hostname}:${port}` : hostname;
43
+ return {
44
+ host,
45
+ address: scheme === 'http' && !loopback(hostname) ? `https://${hostname}` : `${scheme}://${host}`,
46
+ path: segments.join('/'),
47
+ };
48
+ }
49
+ if (['ssh', 'git+ssh', 'ssh+git', 'git'].includes(scheme))
50
+ return { host: hostname, address: `https://${hostname}`, path: segments.join('/') };
51
+ return { refusal: 'unsupported' };
52
+ }
53
+ export function gitLabAddress(value) {
54
+ const trimmed = value.trim();
55
+ if (trimmed.length > 300 || !URL.canParse(trimmed))
56
+ return null;
57
+ const url = new URL(trimmed);
58
+ const hostname = url.hostname.toLowerCase();
59
+ const path = url.pathname.replace(/\/+$/, '');
60
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback(hostname))) ||
61
+ url.username ||
62
+ url.password ||
63
+ url.search ||
64
+ url.hash ||
65
+ isIP(hostname.replace(/^\[|\]$/g, '')) ||
66
+ hostname === 'github.com' ||
67
+ hostname.endsWith('.github.com') ||
68
+ !path.split('/').slice(1).every(pathSegment))
69
+ return null;
70
+ return `${url.protocol}//${url.host.toLowerCase()}${path}`;
71
+ }
72
+ export function projectPath(address, path) {
73
+ const prefix = new URL(address).pathname.replace(/^\/+|\/+$/g, '');
74
+ return prefix && path.startsWith(prefix + '/') ? path.slice(prefix.length + 1) : path;
75
+ }
76
+ function pathSegment(segment) {
77
+ return /^[\w.-]+$/.test(segment) && segment !== '.' && segment !== '..';
78
+ }
79
+ function loopback(hostname) {
80
+ return hostname === 'localhost' || hostname.endsWith('.localhost');
81
+ }
82
+ export class GitLabError extends Error {
83
+ failure;
84
+ address;
85
+ status;
86
+ detail;
87
+ constructor(failure, address, status, detail) {
88
+ super(`GitLab at ${address}: ${failure}${status ? ` (${status})` : ''}${detail ? `: ${detail}` : ''}`);
89
+ this.failure = failure;
90
+ this.address = address;
91
+ this.status = status;
92
+ this.detail = detail;
93
+ this.name = 'GitLabError';
94
+ }
95
+ }
96
+ const certificateCodes = new Set([
97
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
98
+ 'UNABLE_TO_GET_ISSUER_CERT',
99
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
100
+ 'SELF_SIGNED_CERT_IN_CHAIN',
101
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
102
+ 'CERT_HAS_EXPIRED',
103
+ 'CERT_NOT_YET_VALID',
104
+ 'CERT_UNTRUSTED',
105
+ 'CERT_SIGNATURE_FAILURE',
106
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
107
+ ]);
108
+ const connectCodes = new Set([
109
+ 'ENOTFOUND',
110
+ 'EAI_AGAIN',
111
+ 'ECONNREFUSED',
112
+ 'EHOSTUNREACH',
113
+ 'ENETUNREACH',
114
+ 'EADDRNOTAVAIL',
115
+ 'UND_ERR_CONNECT_TIMEOUT',
116
+ ]);
117
+ function networkCode(error) {
118
+ let current = error;
119
+ for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) {
120
+ const code = current.code;
121
+ if (typeof code === 'string')
122
+ return code;
123
+ const tried = current.errors;
124
+ current = current.cause ?? (Array.isArray(tried) ? tried[0] : null);
125
+ }
126
+ return null;
127
+ }
128
+ function gitLabMessage(body) {
129
+ if (!body)
130
+ return null;
131
+ let message = body;
132
+ try {
133
+ const parsed = JSON.parse(body);
134
+ message = parsed.message ?? parsed.error ?? null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ const text = typeof message === 'string' ? message : message ? JSON.stringify(message) : '';
140
+ return text.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 200) || null;
141
+ }
142
+ export const mergeRequestSchema = z.object({
143
+ iid: z.number().int().positive(),
144
+ web_url: z.string().min(1).max(500),
145
+ state: z.string(),
146
+ draft: z.boolean().optional(),
147
+ work_in_progress: z.boolean().optional(),
148
+ sha: z.string().nullish(),
149
+ title: z.string().optional(),
150
+ source_branch: z.string(),
151
+ target_branch: z.string(),
152
+ project_id: z.number().optional(),
153
+ source_project_id: z.number().optional(),
154
+ });
155
+ export function reviewState(request) {
156
+ if (request.state === 'merged')
157
+ return 'merged';
158
+ if (request.state === 'closed')
159
+ return 'closed';
160
+ return request.draft || request.work_in_progress ? 'draft' : 'ready';
161
+ }
162
+ export class GitLabClient {
163
+ timeoutMs;
164
+ constructor(timeoutMs) {
165
+ this.timeoutMs = timeoutMs;
166
+ }
167
+ async user(address, token) {
168
+ await this.call(address, token, 'GET', '/user');
169
+ }
170
+ async scopes(address, token) {
171
+ try {
172
+ const self = z
173
+ .object({ scopes: z.array(z.string()) })
174
+ .safeParse(await this.call(address, token, 'GET', '/personal_access_tokens/self'));
175
+ return self.success ? self.data.scopes : null;
176
+ }
177
+ catch (error) {
178
+ if (error instanceof GitLabError &&
179
+ ['not_found', 'not_allowed', 'invalid_request'].includes(error.failure))
180
+ return null;
181
+ throw error;
182
+ }
183
+ }
184
+ async project(address, token, project) {
185
+ return await this.found(async () => this.shape(address, z.object({ id: z.number() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}`)));
186
+ }
187
+ async branch(address, token, project, branch) {
188
+ const found = await this.found(async () => this.shape(address, z.object({ commit: z.object({ id: z.string(), title: z.string().nullish() }) }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/branches/${encodeURIComponent(branch)}`)));
189
+ return found ? { commit: found.commit.id, title: found.commit.title ?? '' } : null;
190
+ }
191
+ async contains(address, token, project, branch, commit) {
192
+ const query = new URLSearchParams([
193
+ ['refs[]', commit],
194
+ ['refs[]', branch],
195
+ ]);
196
+ try {
197
+ const base = this.shape(address, z.object({ id: z.string() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/merge_base?${query}`));
198
+ return base.id === commit;
199
+ }
200
+ catch (error) {
201
+ if (error instanceof GitLabError &&
202
+ (error.failure === 'not_found' || error.failure === 'invalid_request'))
203
+ return false;
204
+ throw error;
205
+ }
206
+ }
207
+ async mergeRequests(address, token, project, branch, state) {
208
+ const query = new URLSearchParams({
209
+ source_branch: branch,
210
+ order_by: 'created_at',
211
+ sort: 'desc',
212
+ per_page: '20',
213
+ ...(state ? { state } : {}),
214
+ });
215
+ const listed = this.shape(address, z.array(mergeRequestSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project.path)}/merge_requests?${query}`));
216
+ return listed.filter((request) => request.source_branch === branch &&
217
+ (request.source_project_id === undefined || request.source_project_id === project.id));
218
+ }
219
+ async mergeRequest(address, token, project, number) {
220
+ return this.shape(address, mergeRequestSchema, await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`));
221
+ }
222
+ async createMergeRequest(address, token, project, request) {
223
+ return this.shape(address, mergeRequestSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests`, {
224
+ source_branch: request.source,
225
+ target_branch: request.target,
226
+ title: request.title,
227
+ ...(request.description ? { description: request.description } : {}),
228
+ }));
229
+ }
230
+ async retitleMergeRequest(address, token, project, number, title) {
231
+ return this.shape(address, mergeRequestSchema, await this.call(address, token, 'PUT', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`, { title }));
232
+ }
233
+ async found(read) {
234
+ try {
235
+ return await read();
236
+ }
237
+ catch (error) {
238
+ if (error instanceof GitLabError && error.failure === 'not_found')
239
+ return null;
240
+ throw error;
241
+ }
242
+ }
243
+ shape(address, schema, value) {
244
+ const parsed = schema.safeParse(value);
245
+ if (!parsed.success)
246
+ throw new GitLabError('unexpected_answer', address, null, null);
247
+ return parsed.data;
248
+ }
249
+ async call(address, token, method, path, body) {
250
+ let status;
251
+ let text;
252
+ try {
253
+ const response = await fetch(`${address}/api/v4${path}`, {
254
+ method,
255
+ redirect: 'manual',
256
+ headers: {
257
+ 'PRIVATE-TOKEN': token,
258
+ Accept: 'application/json',
259
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
260
+ },
261
+ ...(body ? { body: JSON.stringify(body) } : {}),
262
+ signal: AbortSignal.timeout(this.timeoutMs),
263
+ });
264
+ status = response.status;
265
+ text = await response.text();
266
+ }
267
+ catch (error) {
268
+ const code = networkCode(error);
269
+ if (code && certificateCodes.has(code))
270
+ throw new GitLabError('untrusted_certificate', address, null, code);
271
+ if ((code && connectCodes.has(code)) || method === 'GET')
272
+ throw new GitLabError('unreachable', address, null, code);
273
+ throw new GitLabError('unknown_outcome', address, null, code);
274
+ }
275
+ if (status >= 200 && status < 300) {
276
+ try {
277
+ return JSON.parse(text);
278
+ }
279
+ catch {
280
+ throw new GitLabError('unexpected_answer', address, status, null);
281
+ }
282
+ }
283
+ const failure = status >= 300 && status < 400
284
+ ? 'redirected'
285
+ : status === 401
286
+ ? 'token_rejected'
287
+ : status === 403
288
+ ? 'not_allowed'
289
+ : status === 404
290
+ ? 'not_found'
291
+ : status === 409
292
+ ? 'conflict'
293
+ : status === 429
294
+ ? 'rate_limited'
295
+ : status >= 500
296
+ ? 'server_error'
297
+ : 'invalid_request';
298
+ throw new GitLabError(failure, address, status, gitLabMessage(text));
299
+ }
300
+ }
301
+ const savedAddresses = z.object({
302
+ schemaVersion: z.literal(1),
303
+ principalHash: z.string().min(1),
304
+ addresses: z.array(z.object({ host: z.string().min(1), address: z.string().min(1) })),
305
+ });
306
+ export class GitLabTokens {
307
+ stateRoot;
308
+ credentials;
309
+ path;
310
+ queue = Promise.resolve();
311
+ constructor(stateRoot, credentials) {
312
+ this.stateRoot = stateRoot;
313
+ this.credentials = credentials;
314
+ this.path = join(stateRoot, 'gitlab-addresses.json');
315
+ }
316
+ async address(host) {
317
+ return (await this.current())?.addresses.find((entry) => entry.host === host)?.address ?? null;
318
+ }
319
+ async forLink(host, repository) {
320
+ const addresses = [...new Set((await this.current())?.addresses.map((entry) => entry.address))]
321
+ .filter((address) => {
322
+ const url = new URL(address);
323
+ const prefix = url.pathname.replace(/^\/+/, '');
324
+ return url.host === host && (!prefix || repository.startsWith(prefix + '/'));
325
+ })
326
+ .sort((left, right) => right.length - left.length);
327
+ return addresses[0]
328
+ ? { address: addresses[0], project: projectPath(addresses[0], repository) }
329
+ : null;
330
+ }
331
+ async token(address) {
332
+ const principal = await this.principal();
333
+ return principal ? await this.credentials.get(tokenName(principal, address)) : null;
334
+ }
335
+ async any() {
336
+ return Boolean((await this.current())?.addresses.length);
337
+ }
338
+ async save(host, address, token) {
339
+ await this.exclusive(async () => {
340
+ const principal = await this.principal();
341
+ if (!principal)
342
+ throw new Error('Sign in to Engineering Memory before saving a GitLab token.');
343
+ let saved = await this.read();
344
+ if (saved && saved.principalHash !== principal) {
345
+ await this.deleteTokens(saved);
346
+ saved = null;
347
+ }
348
+ const previous = saved?.addresses.find((entry) => entry.host === host)?.address;
349
+ await this.credentials.set(tokenName(principal, address), token);
350
+ const addresses = [
351
+ ...(saved?.addresses ?? []).filter((entry) => entry.host !== host),
352
+ { host, address },
353
+ ];
354
+ await writeJson(this.path, { schemaVersion: 1, principalHash: principal, addresses }, this.stateRoot);
355
+ if (previous && !addresses.some((entry) => entry.address === previous))
356
+ await this.credentials.delete(tokenName(principal, previous));
357
+ });
358
+ }
359
+ async remove(address) {
360
+ await this.exclusive(async () => {
361
+ const principal = await this.principal();
362
+ if (!principal)
363
+ return;
364
+ await this.credentials.delete(tokenName(principal, address));
365
+ const saved = await this.read();
366
+ if (saved?.principalHash !== principal)
367
+ return;
368
+ await writeJson(this.path, { ...saved, addresses: saved.addresses.filter((entry) => entry.address !== address) }, this.stateRoot);
369
+ });
370
+ }
371
+ async clear() {
372
+ await this.exclusive(async () => {
373
+ const saved = await this.read();
374
+ if (saved)
375
+ await this.deleteTokens(saved);
376
+ await removeFile(this.path, this.stateRoot);
377
+ });
378
+ }
379
+ async deleteTokens(saved) {
380
+ for (const address of new Set(saved.addresses.map((entry) => entry.address)))
381
+ await this.credentials.delete(tokenName(saved.principalHash, address));
382
+ }
383
+ async current() {
384
+ const principal = await this.principal();
385
+ const saved = principal ? await this.read() : null;
386
+ return saved?.principalHash === principal ? saved : null;
387
+ }
388
+ async read() {
389
+ const parsed = savedAddresses.safeParse(await readJson(this.path, this.stateRoot));
390
+ return parsed.success ? parsed.data : null;
391
+ }
392
+ async principal() {
393
+ const accessToken = await this.credentials.get('access-token');
394
+ if (!accessToken)
395
+ return null;
396
+ try {
397
+ return principalFingerprint(accessToken);
398
+ }
399
+ catch {
400
+ return null;
401
+ }
402
+ }
403
+ async exclusive(action) {
404
+ const previous = this.queue;
405
+ let release = () => undefined;
406
+ this.queue = new Promise((resolvePromise) => {
407
+ release = resolvePromise;
408
+ });
409
+ await previous;
410
+ try {
411
+ await action();
412
+ }
413
+ finally {
414
+ release();
415
+ }
416
+ }
417
+ }
418
+ function tokenName(principal, address) {
419
+ return `gitlab-token-${sha256(`${principal}\n${address}`).slice(0, 32)}`;
420
+ }
421
+ //# sourceMappingURL=gitlab.js.map
@@ -52,6 +52,13 @@ export const backendRecoveryOperationNames = [
52
52
  'task.resolve_pending_delivery',
53
53
  'task.delivery',
54
54
  'task.review_request',
55
+ 'project.review_policy',
56
+ 'review.queue',
57
+ 'review.get',
58
+ 'review.start',
59
+ 'review.submit',
60
+ 'review.reject_finding',
61
+ 'review.conclude',
55
62
  ];
56
63
  const backendRecoveryOperations = new Set(backendRecoveryOperationNames);
57
64
  const browserSigninRecovery = 'auth.signin_browser';