@foss.global/codefeed 1.11.1 → 1.13.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,378 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as validation from './forgeinventory.validation.js';
3
+
4
+ export { ForgeInventoryError } from './forgeinventory.validation.js';
5
+
6
+ export interface IForgeInventoryOptions {
7
+ kind: 'gitea' | 'gitlab';
8
+ instanceId: string;
9
+ /** Instance root, including an optional deployment subpath. HTTPS is required. */
10
+ baseUrl: string;
11
+ /** Personal access token, retained only in this adapter's memory. */
12
+ token?: string;
13
+ pageSize?: number;
14
+ maxPages?: number;
15
+ requestTimeoutMs?: number;
16
+ maxResponseBytes?: number;
17
+ }
18
+
19
+ export interface IForgeInventoryRequest {
20
+ cursor?: string;
21
+ signal?: AbortSignal;
22
+ /** Optional lower response-body budget for this request. */
23
+ maxResponseBytes?: number;
24
+ }
25
+
26
+ export interface IForgeInventoryMetrics {
27
+ requests: number;
28
+ responseBytes: number;
29
+ rateLimitedRequests: number;
30
+ }
31
+
32
+ export type TForgeInventoryVersionInspection =
33
+ | { status: 'observed'; version: string; observation: plugins.fossInterfaces.data.IForgeInventoryObservation }
34
+ | { status: 'unverified'; reason: string; observation: plugins.fossInterfaces.data.IForgeInventoryObservation };
35
+
36
+ type TCollection = 'repositories' | 'namespaces' | 'namespaceDirectory';
37
+ interface IContinuation {
38
+ version: 2;
39
+ collection: TCollection;
40
+ /** Endpoint page, reset when a directory moves from organizations to users. */
41
+ page: number;
42
+ /** Completed requests across every endpoint in this collection. */
43
+ pagesRead: number;
44
+ phase: 'organizations' | 'users' | null;
45
+ after: string | null;
46
+ }
47
+
48
+ /** Read-only, caller-visible API observations. This adapter never claims admin completeness. */
49
+ export class ForgeInventoryAdapter {
50
+ readonly #options: Required<Omit<IForgeInventoryOptions, 'token'>> & { token?: string };
51
+ readonly #cursorKey = plugins.crypto.randomBytes(32);
52
+ readonly #metrics: IForgeInventoryMetrics = { requests: 0, responseBytes: 0, rateLimitedRequests: 0 };
53
+
54
+ constructor(optionsArg: IForgeInventoryOptions) {
55
+ const baseUrl = validation.httpUrl(optionsArg.baseUrl);
56
+ if (!baseUrl || !baseUrl.startsWith('https:') || !optionsArg.instanceId.trim() ||
57
+ !['gitea', 'gitlab'].includes(optionsArg.kind) ||
58
+ (optionsArg.token !== undefined && (!optionsArg.token || /[\s\u0000-\u001f\u007f]/.test(optionsArg.token)))) {
59
+ throw new validation.ForgeInventoryError('invalid-options');
60
+ }
61
+ this.#options = {
62
+ ...optionsArg,
63
+ baseUrl: baseUrl.replace(/\/$/, ''),
64
+ pageSize: optionsArg.pageSize ?? 50,
65
+ maxPages: optionsArg.maxPages ?? 1000,
66
+ requestTimeoutMs: optionsArg.requestTimeoutMs ?? 30_000,
67
+ maxResponseBytes: optionsArg.maxResponseBytes ?? 8 * 1024 * 1024,
68
+ };
69
+ for (const [name, maximum] of [
70
+ ['pageSize', 100], ['maxPages', 100_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024],
71
+ ] as const) {
72
+ const value = this.#options[name];
73
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
74
+ }
75
+ }
76
+
77
+ public get instance(): plugins.fossInterfaces.data.IForgeInventoryInstance {
78
+ return { instanceId: this.#options.instanceId, kind: this.#options.kind, version: null };
79
+ }
80
+
81
+ public get metrics(): IForgeInventoryMetrics { return { ...this.#metrics }; }
82
+
83
+ public observation(startedAt: string): plugins.fossInterfaces.data.IForgeInventoryObservation {
84
+ return {
85
+ startedAt, finishedAt: new Date().toISOString(),
86
+ access: this.#options.token ? 'callerVisible' : 'publicOnly', consistency: { status: 'live' },
87
+ };
88
+ }
89
+
90
+ public listRepositories(requestArg: IForgeInventoryRequest = {}): Promise<plugins.fossInterfaces.data.IForgeInventoryPage<plugins.fossInterfaces.data.IForgeInventoryRepository>> {
91
+ return this.page('repositories', (item) => this.repository(item), requestArg);
92
+ }
93
+
94
+ /** Organizations/groups only. User namespaces are not enumerated by these endpoints. */
95
+ public listNamespaces(requestArg: IForgeInventoryRequest = {}): Promise<plugins.fossInterfaces.data.IForgeInventoryPage<plugins.fossInterfaces.data.IForgeInventoryNamespace>> {
96
+ return this.page('namespaces', (item) => this.namespace(item), requestArg);
97
+ }
98
+
99
+ /** Gitea admin organizations/users or GitLab caller-visible group/personal namespaces. */
100
+ public async listNamespaceDirectory(requestArg: IForgeInventoryRequest = {}): Promise<plugins.fossInterfaces.data.IForgeInventoryPage<plugins.fossInterfaces.data.IForgeInventoryNamespace>> {
101
+ if (!this.#options.token) throw new validation.ForgeInventoryError('namespace-directory-requires-token');
102
+ return this.page('namespaceDirectory', (item, state) => this.directoryNamespace(item, state), requestArg);
103
+ }
104
+
105
+ /** A fresh, bounded observation; failures never retain a previously observed version. */
106
+ public async inspectVersion(requestArg: Omit<IForgeInventoryRequest, 'cursor'> = {}): Promise<TForgeInventoryVersionInspection> {
107
+ const startedAt = new Date().toISOString();
108
+ try {
109
+ const { body } = await this.request(new URL(`${this.#options.baseUrl}/api/${this.#options.kind === 'gitea' ? 'v1' : 'v4'}/version`), requestArg);
110
+ const version = validation.record(body).version;
111
+ // Preserve release suffixes while rejecting arbitrary bodies, URLs and control characters.
112
+ if (typeof version !== 'string' || version.length > 128 || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9][A-Za-z0-9.+-]*)?$/.test(version)) {
113
+ throw new validation.ForgeInventoryError('invalid-server-version');
114
+ }
115
+ return { status: 'observed', version, observation: this.observation(startedAt) };
116
+ } catch (error) {
117
+ return {
118
+ status: 'unverified', observation: this.observation(startedAt),
119
+ reason: error instanceof validation.ForgeInventoryError ? error.message : 'Version inspection failed.',
120
+ };
121
+ }
122
+ }
123
+
124
+ private identity(idNamespace: string, value: unknown): plugins.fossInterfaces.data.TForgeInventoryIdentity {
125
+ const objectId = validation.decimal(value);
126
+ if (objectId === '0') throw new validation.ForgeInventoryError('invalid-identity');
127
+ return { status: 'identified', ref: { instanceId: this.#options.instanceId, idNamespace, objectId, parentObjectId: null } };
128
+ }
129
+
130
+ private namespace(itemArg: unknown): plugins.fossInterfaces.data.IForgeInventoryNamespace {
131
+ const item = validation.record(itemArg);
132
+ if (this.#options.kind === 'gitea') {
133
+ const pathSegments = validation.segments(item.name);
134
+ if (pathSegments.length !== 1) throw new validation.ForgeInventoryError('invalid-organization-path');
135
+ return {
136
+ identity: this.identity('gitea.user.id', item.id), kind: 'organization', parent: null,
137
+ location: { pathSegments, webUrl: null },
138
+ visibility: validation.visibility(item.visibility === 'limited' ? 'internal' : item.visibility),
139
+ };
140
+ }
141
+ return {
142
+ identity: this.identity('gitlab.group.id', item.id), kind: 'group',
143
+ parent: item.parent_id === null ? null : item.parent_id === undefined
144
+ ? { status: 'unresolved', reason: 'Parent group was not exposed.' }
145
+ : this.identity('gitlab.group.id', item.parent_id),
146
+ location: { pathSegments: validation.segments(item.full_path), webUrl: validation.httpUrl(item.web_url) },
147
+ visibility: validation.visibility(item.visibility),
148
+ };
149
+ }
150
+
151
+ private directoryNamespace(itemArg: unknown, state: IContinuation): plugins.fossInterfaces.data.IForgeInventoryNamespace {
152
+ const item = validation.record(itemArg);
153
+ if (this.#options.kind === 'gitea') {
154
+ if (state.phase === 'organizations') return this.namespace(item);
155
+ const pathSegments = validation.segments(item.login);
156
+ if (pathSegments.length !== 1) throw new validation.ForgeInventoryError('invalid-user-namespace-path');
157
+ return {
158
+ identity: this.identity('gitea.user.id', item.id), kind: 'user', parent: null,
159
+ location: { pathSegments, webUrl: validation.httpUrl(item.html_url) },
160
+ visibility: validation.visibility(item.visibility === 'limited' ? 'internal' : item.visibility),
161
+ };
162
+ }
163
+ if (item.kind === 'group') return this.namespace(item);
164
+ if (item.kind !== 'user') throw new validation.ForgeInventoryError('invalid-namespace-kind');
165
+ const pathSegments = validation.segments(item.full_path);
166
+ if (pathSegments.length !== 1 || (item.parent_id !== undefined && item.parent_id !== null)) {
167
+ throw new validation.ForgeInventoryError('invalid-user-namespace-parent');
168
+ }
169
+ return {
170
+ identity: this.identity('gitlab.namespace.id', item.id), kind: 'user', parent: null,
171
+ location: { pathSegments, webUrl: validation.httpUrl(item.web_url) },
172
+ visibility: validation.visibility(item.visibility),
173
+ };
174
+ }
175
+
176
+ private repository(itemArg: unknown): plugins.fossInterfaces.data.IForgeInventoryRepository {
177
+ const item = validation.record(itemArg);
178
+ if (this.#options.kind === 'gitea') {
179
+ const owner = validation.record(item.owner);
180
+ const pathSegments = [...validation.segments(owner.login), ...validation.segments(item.name)];
181
+ if (pathSegments.length !== 2) throw new validation.ForgeInventoryError('invalid-repository-path');
182
+ const privateRepo = validation.optionalBoolean(item.private);
183
+ const internalRepo = validation.optionalBoolean(item.internal);
184
+ return {
185
+ identity: this.identity('gitea.repository.id', item.id), namespace: this.identity('gitea.user.id', owner.id),
186
+ location: { pathSegments, webUrl: validation.httpUrl(item.html_url) },
187
+ description: validation.optionalString(item.description),
188
+ visibility: privateRepo === true ? 'private' : internalRepo === true ? 'internal' : privateRepo === false && internalRepo === false ? 'public' : null,
189
+ archived: validation.optionalBoolean(item.archived), defaultBranch: validation.optionalString(item.default_branch),
190
+ httpCloneUrl: validation.httpUrl(item.clone_url), sshCloneUrl: validation.sshUrl(item.ssh_url),
191
+ gitSizeBytes: validation.unknownSize('Gitea size is not a separate Git byte measurement.'),
192
+ };
193
+ }
194
+ const namespace = validation.record(item.namespace);
195
+ if (namespace.kind !== 'group' && namespace.kind !== 'user') throw new validation.ForgeInventoryError('invalid-namespace-kind');
196
+ return {
197
+ identity: this.identity('gitlab.project.id', item.id),
198
+ namespace: this.identity(namespace.kind === 'group' ? 'gitlab.group.id' : 'gitlab.namespace.id', namespace.id),
199
+ location: { pathSegments: validation.segments(item.path_with_namespace), webUrl: validation.httpUrl(item.web_url) },
200
+ description: validation.optionalString(item.description), visibility: validation.visibility(item.visibility),
201
+ archived: validation.optionalBoolean(item.archived), defaultBranch: validation.optionalString(item.default_branch),
202
+ httpCloneUrl: validation.httpUrl(item.http_url_to_repo), sshCloneUrl: validation.sshUrl(item.ssh_url_to_repo),
203
+ gitSizeBytes: validation.gitlabSize(item.statistics),
204
+ };
205
+ }
206
+
207
+ private url(state: IContinuation): URL {
208
+ const gitea = this.#options.kind === 'gitea';
209
+ const endpoint = state.collection === 'repositories' ? (gitea ? 'repos/search' : 'projects')
210
+ : state.collection === 'namespaceDirectory' ? (gitea ? `admin/${state.phase === 'organizations' ? 'orgs' : 'users'}` : 'namespaces')
211
+ : (gitea ? 'orgs' : 'groups');
212
+ const url = new URL(`${this.#options.baseUrl}/api/${gitea ? 'v1' : 'v4'}/${endpoint}`);
213
+ url.searchParams.set(gitea ? 'limit' : 'per_page', String(this.#options.pageSize));
214
+ if (gitea) {
215
+ url.searchParams.set('page', String(state.page));
216
+ if (state.collection === 'repositories') {
217
+ url.searchParams.set('sort', 'id'); url.searchParams.set('order', 'asc'); url.searchParams.set('private', 'true');
218
+ }
219
+ } else if (state.collection === 'namespaceDirectory') {
220
+ url.searchParams.set('page', String(state.page));
221
+ } else {
222
+ url.searchParams.set('order_by', 'id'); url.searchParams.set('sort', 'asc');
223
+ if (state.collection === 'repositories') {
224
+ url.searchParams.set('pagination', 'keyset'); url.searchParams.set('statistics', 'true');
225
+ if (state.after) url.searchParams.set('id_after', state.after);
226
+ } else {
227
+ url.searchParams.set('all_available', 'true'); url.searchParams.set('page', String(state.page));
228
+ }
229
+ }
230
+ return url;
231
+ }
232
+
233
+ private cursor(state: IContinuation): string {
234
+ const payload = Buffer.from(JSON.stringify(state)).toString('base64url');
235
+ return `${payload}.${plugins.crypto.createHmac('sha256', this.#cursorKey).update(payload).digest('base64url')}`;
236
+ }
237
+
238
+ private continuation(collection: TCollection, cursor?: string): IContinuation {
239
+ const directory = collection === 'namespaceDirectory' && this.#options.kind === 'gitea';
240
+ if (cursor === undefined) return { version: 2, collection, page: 1, pagesRead: 0, phase: directory ? 'organizations' : null, after: null };
241
+ try {
242
+ if (cursor.length > 2048) throw new Error();
243
+ const [payload, signature, extra] = cursor.split('.');
244
+ const expected = plugins.crypto.createHmac('sha256', this.#cursorKey).update(payload).digest('base64url');
245
+ if (extra !== undefined || !signature || signature.length !== expected.length ||
246
+ !plugins.crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) throw new Error();
247
+ const state = JSON.parse(Buffer.from(payload, 'base64url').toString()) as IContinuation;
248
+ if (state.version !== 2 || state.collection !== collection || !Number.isSafeInteger(state.page) ||
249
+ !Number.isSafeInteger(state.pagesRead) || state.pagesRead < 1 || state.pagesRead >= this.#options.maxPages ||
250
+ state.page < 1 || state.page > state.pagesRead + 1 ||
251
+ (directory ? state.phase !== 'organizations' && state.phase !== 'users' : state.phase !== null) ||
252
+ ((collection !== 'repositories' || this.#options.kind !== 'gitlab') && state.after !== null) ||
253
+ (state.after !== null && validation.decimal(state.after) !== state.after)) throw new Error();
254
+ return state;
255
+ } catch { throw new validation.ForgeInventoryError('invalid-cursor'); }
256
+ }
257
+
258
+ private async page<TItem>(collection: TCollection, map: (value: unknown, state: IContinuation) => TItem, requestArg: IForgeInventoryRequest): Promise<plugins.fossInterfaces.data.IForgeInventoryPage<TItem>> {
259
+ const state = this.continuation(collection, requestArg.cursor);
260
+ const startedAt = new Date().toISOString();
261
+ const url = this.url(state);
262
+ const { body, headers } = await this.request(url, requestArg);
263
+ let rows: unknown = body;
264
+ if (this.#options.kind === 'gitea' && collection === 'repositories') {
265
+ const envelope = validation.record(body);
266
+ if (envelope.ok !== true) throw new validation.ForgeInventoryError('search-not-successful');
267
+ rows = envelope.data;
268
+ }
269
+ if (!Array.isArray(rows) || rows.length > this.#options.pageSize) throw new validation.ForgeInventoryError('invalid-page');
270
+ const items = rows.map((item) => map(item, state));
271
+ let pagination: plugins.fossInterfaces.data.TForgeInventoryPagination;
272
+ try {
273
+ const next = this.next(state, url, headers, rows);
274
+ pagination = next === null ? { status: 'exhausted' } : state.pagesRead + 1 >= this.#options.maxPages
275
+ ? { status: 'incomplete', reason: 'Configured page limit reached.' }
276
+ : { status: 'more', cursor: this.cursor(next) };
277
+ } catch {
278
+ pagination = { status: 'incomplete', reason: 'Invalid or inconsistent pagination metadata.' };
279
+ }
280
+ return { schemaVersion: 1, instance: this.instance, scope: { kind: 'instance' }, observation: this.observation(startedAt), items, pagination };
281
+ }
282
+
283
+ private next(state: IContinuation, current: URL, headers: Headers, rows: unknown[]): IContinuation | null {
284
+ const keyset = this.#options.kind === 'gitlab' && state.collection === 'repositories';
285
+ let previousId = BigInt(state.after ?? '0');
286
+ if (keyset) for (const row of rows) {
287
+ const id = BigInt(validation.decimal(validation.record(row).id));
288
+ if (id <= previousId) throw new Error();
289
+ previousId = id;
290
+ }
291
+ const link = headers.get('link');
292
+ let nextUrl: URL | null = null;
293
+ if (link !== null) {
294
+ if (link.length > 16_384) throw new Error();
295
+ for (const entry of link.split(',')) {
296
+ const match = entry.trim().match(/^<([^<>]+)>;\s*rel="(next|prev|first|last)"$/);
297
+ if (!match) throw new Error();
298
+ if (match[2] !== 'next') continue;
299
+ if (nextUrl !== null) throw new Error();
300
+ nextUrl = new URL(match[1], current);
301
+ if (nextUrl.origin !== current.origin || nextUrl.pathname !== current.pathname || nextUrl.username || nextUrl.password || nextUrl.hash) throw new Error();
302
+ const expected = new URL(current);
303
+ const changing = keyset ? 'id_after' : 'page';
304
+ expected.searchParams.delete(changing);
305
+ const actual = new URL(nextUrl);
306
+ if (actual.searchParams.getAll(changing).length !== 1) throw new Error();
307
+ actual.searchParams.delete(changing);
308
+ expected.searchParams.sort(); actual.searchParams.sort();
309
+ if (expected.search !== actual.search) throw new Error();
310
+ }
311
+ }
312
+ const xNext = headers.get('x-next-page');
313
+ if (!keyset && xNext !== null && xNext !== '' && validation.decimal(xNext) !== String(state.page + 1)) throw new Error();
314
+ if (rows.length === 0) {
315
+ if (nextUrl || xNext) throw new Error();
316
+ if (state.phase === 'organizations') return { ...state, phase: 'users', page: 1, pagesRead: state.pagesRead + 1 };
317
+ return null;
318
+ }
319
+ if (keyset) {
320
+ if (!nextUrl) return null; // GitLab keyset exhaustion is signalled by no next Link.
321
+ const after = validation.decimal(nextUrl.searchParams.get('id_after'));
322
+ if (after !== previousId.toString()) throw new Error();
323
+ return { ...state, page: state.page + 1, pagesRead: state.pagesRead + 1, after };
324
+ }
325
+ if (nextUrl && validation.decimal(nextUrl.searchParams.get('page')) !== String(state.page + 1)) throw new Error();
326
+ if (nextUrl && xNext === '') throw new Error();
327
+ // A short page can reflect a server page-size cap. Probe the following page
328
+ // even without Link rather than silently truncating at that cap.
329
+ return { ...state, page: state.page + 1, pagesRead: state.pagesRead + 1 };
330
+ }
331
+
332
+ private async request(url: URL, requestArg: IForgeInventoryRequest): Promise<{ body: unknown; headers: Headers }> {
333
+ const { signal } = requestArg;
334
+ const maxBytes = requestArg.maxResponseBytes ?? this.#options.maxResponseBytes;
335
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new validation.ForgeInventoryError('invalid-response-budget');
336
+ const controller = new AbortController();
337
+ const abort = () => controller.abort();
338
+ if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
339
+ signal?.addEventListener('abort', abort, { once: true });
340
+ const timeout = setTimeout(abort, this.#options.requestTimeoutMs);
341
+ const deadline = Date.now() + this.#options.requestTimeoutMs;
342
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
343
+ try {
344
+ const headers = new Headers({ accept: 'application/json' });
345
+ if (this.#options.token) headers.set(this.#options.kind === 'gitea' ? 'authorization' : 'private-token',
346
+ this.#options.kind === 'gitea' ? `token ${this.#options.token}` : this.#options.token);
347
+ this.#metrics.requests++;
348
+ const response = await fetch(url, { method: 'GET', headers, redirect: 'error', credentials: 'omit', signal: controller.signal });
349
+ reader = response.body?.getReader();
350
+ if (response.status === 429) this.#metrics.rateLimitedRequests++;
351
+ if (response.status !== 200) throw new validation.ForgeInventoryError(`http-${response.status}`);
352
+ if (response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json' || !reader) throw new validation.ForgeInventoryError('invalid-content-type');
353
+ const chunks: Uint8Array[] = [];
354
+ let bytes = 0;
355
+ while (true) {
356
+ const result = await reader.read();
357
+ if (result.done) break;
358
+ bytes += result.value.byteLength; this.#metrics.responseBytes += result.value.byteLength;
359
+ if (bytes > Math.min(maxBytes, this.#options.maxResponseBytes)) throw new validation.ForgeInventoryError('response-size-limit');
360
+ if (controller.signal.aborted || Date.now() >= deadline) throw new validation.ForgeInventoryError('request-aborted-or-timed-out');
361
+ chunks.push(result.value);
362
+ }
363
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
364
+ const body = JSON.parse(text) as unknown;
365
+ if (controller.signal.aborted || Date.now() >= deadline) throw new validation.ForgeInventoryError('request-aborted-or-timed-out');
366
+ return { body, headers: response.headers };
367
+ } catch (error) {
368
+ if (error instanceof validation.ForgeInventoryError) throw error;
369
+ throw new validation.ForgeInventoryError(controller.signal.aborted ? 'request-aborted-or-timed-out' : 'request-or-json-failed');
370
+ } finally {
371
+ controller.abort(); clearTimeout(timeout); signal?.removeEventListener('abort', abort);
372
+ if (reader) {
373
+ try { await reader.cancel(); } catch { /* The request has already been aborted. */ }
374
+ reader.releaseLock();
375
+ }
376
+ }
377
+ }
378
+ }
@@ -0,0 +1,172 @@
1
+ import type * as plugins from './plugins.js';
2
+ import { ForgeInventoryAdapter, ForgeInventoryError } from './classes.forgeinventory.js';
3
+ import type { IForgeInventoryMetrics, IForgeInventoryRequest, IForgeInventoryOptions, TForgeInventoryVersionInspection } from './classes.forgeinventory.js';
4
+ import { unknownSize } from './forgeinventory.validation.js';
5
+
6
+ export interface IForgeInventoryScanOptions {
7
+ signal?: AbortSignal;
8
+ /** Maximum distinct items retained per collection. */
9
+ maxItems?: number;
10
+ /** Deadline for the complete scan, including body reads. */
11
+ timeoutMs?: number;
12
+ /** Maximum response bytes consumed across all collection and version requests. */
13
+ maxScanResponseBytes?: number;
14
+ /** Probe the server version at the beginning of this scan. Defaults to false. */
15
+ inspectVersion?: boolean;
16
+ /** Include personal namespaces through authenticated directory endpoints. Defaults to false. */
17
+ namespaceDirectory?: boolean;
18
+ }
19
+
20
+ export interface IForgeInventoryScan {
21
+ report: plugins.fossInterfaces.data.IForgeInventoryReport;
22
+ repositories: plugins.fossInterfaces.data.IForgeInventoryRepository[];
23
+ namespaces: plugins.fossInterfaces.data.IForgeInventoryNamespace[];
24
+ metrics: IForgeInventoryMetrics;
25
+ versionInspection: TForgeInventoryVersionInspection | { status: 'notInspected'; reason: string };
26
+ }
27
+
28
+ const entityClasses = {
29
+ namespaces: true, users: true, teams: true, memberships: true, repositories: true,
30
+ gitRefs: true, gitObjects: true, protectedRefs: true, publicSshKeys: true, deployKeys: true,
31
+ issues: true, issueComments: true, pullRequests: true, reviews: true, reviewComments: true,
32
+ labels: true, milestones: true, assignees: true, reactions: true, releases: true,
33
+ releaseAssets: true, lfsObjects: true, packages: true, packageVersions: true, packageBlobs: true,
34
+ webhooks: true, webhookDeliveries: true, ciWorkflows: true, ciRuns: true, ciArtifacts: true,
35
+ wikis: true, projects: true,
36
+ } satisfies Record<plugins.fossInterfaces.data.TForgeInventoryEntityClass, true>;
37
+
38
+ /** A bounded, disposable observation; callers own any durable storage and migration decisions. */
39
+ export class ForgeInventoryScanner {
40
+ readonly #adapter: ForgeInventoryAdapter;
41
+ #running = false;
42
+
43
+ constructor(optionsArg: IForgeInventoryOptions) {
44
+ this.#adapter = new ForgeInventoryAdapter(optionsArg);
45
+ }
46
+
47
+ public async scan(optionsArg: IForgeInventoryScanOptions = {}): Promise<IForgeInventoryScan> {
48
+ const maxItems = optionsArg.maxItems ?? 100_000;
49
+ const timeoutMs = optionsArg.timeoutMs ?? 60_000;
50
+ const maxScanResponseBytes = optionsArg.maxScanResponseBytes ?? 64 * 1024 * 1024;
51
+ if (!Number.isSafeInteger(maxItems) || maxItems < 1 || maxItems > 1_000_000 ||
52
+ !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3_600_000 ||
53
+ !Number.isSafeInteger(maxScanResponseBytes) || maxScanResponseBytes < 1 || maxScanResponseBytes > 1024 * 1024 * 1024) {
54
+ throw new ForgeInventoryError('invalid-scan-limits');
55
+ }
56
+ if ((optionsArg.inspectVersion !== undefined && typeof optionsArg.inspectVersion !== 'boolean') ||
57
+ (optionsArg.namespaceDirectory !== undefined && typeof optionsArg.namespaceDirectory !== 'boolean')) {
58
+ throw new ForgeInventoryError('invalid-scan-options');
59
+ }
60
+ if (this.#running) throw new ForgeInventoryError('scan-already-running');
61
+ this.#running = true;
62
+ const controller = new AbortController();
63
+ const abort = () => controller.abort();
64
+ if (optionsArg.signal?.aborted) controller.abort();
65
+ optionsArg.signal?.addEventListener('abort', abort, { once: true });
66
+ const timeout = setTimeout(abort, timeoutMs);
67
+ const before = this.#adapter.metrics;
68
+ const deadline = Date.now() + timeoutMs;
69
+ const remainingBytes = () => maxScanResponseBytes - (this.#adapter.metrics.responseBytes - before.responseBytes);
70
+ const collections = Object.fromEntries(Object.keys(entityClasses).map((key) => [key, {
71
+ capability: { status: 'unverified', reason: 'No inventory reader has inspected this entity class.' },
72
+ result: { status: 'notInspected', reason: 'Outside this repository and namespace scan.' },
73
+ }])) as plugins.fossInterfaces.data.IForgeInventoryReport['collections'];
74
+ try {
75
+ if (Date.now() >= deadline) controller.abort();
76
+ const versionInspection: IForgeInventoryScan['versionInspection'] = optionsArg.inspectVersion
77
+ ? await this.#adapter.inspectVersion({ signal: controller.signal, maxResponseBytes: remainingBytes() })
78
+ : { status: 'notInspected', reason: 'Server version inspection was not requested.' };
79
+ const namespaces = await this.collect((request) => optionsArg.namespaceDirectory
80
+ ? this.#adapter.listNamespaceDirectory(request) : this.#adapter.listNamespaces(request), maxItems, controller.signal, deadline, remainingBytes);
81
+ const repositories = await this.collect((request) => this.#adapter.listRepositories(request), maxItems, controller.signal, deadline, remainingBytes);
82
+ const namespaceLimitation = 'Only organization/group namespaces were inspected; personal namespaces and full private namespace coverage remain uninspected.';
83
+ collections.namespaces = {
84
+ capability: this.capability(namespaces, [
85
+ ...(optionsArg.namespaceDirectory ? [] : [namespaceLimitation]),
86
+ 'Live API visibility does not prove administrator completeness.',
87
+ 'Namespace metadata does not establish user identity or permission inventory.',
88
+ ]),
89
+ result: !optionsArg.namespaceDirectory && namespaces.result.status === 'enumerated' ? {
90
+ status: 'incomplete', observation: namespaces.result.observation,
91
+ observedCount: namespaces.result.count, reason: namespaceLimitation,
92
+ } : namespaces.result,
93
+ };
94
+ const repositoryResult = repositories.result;
95
+ if (repositoryResult.status === 'enumerated') {
96
+ const measurements = repositories.items.map((item) => item.gitSizeBytes);
97
+ repositoryResult.sizeBytes = measurements.length > 0 && measurements.every((size) => size.status === 'measured')
98
+ ? { status: 'measured', value: measurements.reduce((sum, size) => sum + (size.status === 'measured' ? BigInt(size.value) : 0n), 0n).toString(), accuracy: 'estimate' }
99
+ : unknownSize('Separate Git byte measurements were not available for every repository.');
100
+ }
101
+ collections.repositories = {
102
+ capability: this.capability(repositories, ['Only API-visible repositories are enumerated.', 'Live pagination is not a frozen export.', 'Git size statistics do not measure outgoing push bytes.']),
103
+ result: repositoryResult,
104
+ };
105
+ const after = this.#adapter.metrics;
106
+ return {
107
+ report: {
108
+ schemaVersion: 1,
109
+ instance: { ...this.#adapter.instance, version: versionInspection.status === 'observed' ? versionInspection.version : null },
110
+ scope: { kind: 'instance' }, collections,
111
+ additionalCollections: optionsArg.namespaceDirectory ? [] : [{
112
+ name: this.#adapter.instance.kind === 'gitea' ? 'gitea.organizations' : 'gitlab.groups',
113
+ capability: this.capability(namespaces, ['Endpoint visibility and live pagination limits apply.']),
114
+ result: namespaces.result,
115
+ }],
116
+ },
117
+ repositories: repositories.items, namespaces: namespaces.items, versionInspection,
118
+ metrics: {
119
+ requests: after.requests - before.requests,
120
+ responseBytes: after.responseBytes - before.responseBytes,
121
+ rateLimitedRequests: after.rateLimitedRequests - before.rateLimitedRequests,
122
+ },
123
+ };
124
+ } finally {
125
+ controller.abort(); clearTimeout(timeout); optionsArg.signal?.removeEventListener('abort', abort);
126
+ this.#running = false;
127
+ }
128
+ }
129
+
130
+ private capability(
131
+ collection: { items: unknown[]; result: plugins.fossInterfaces.data.TForgeInventoryCollectionResult }, limitations: string[],
132
+ ): plugins.fossInterfaces.data.TForgeInventoryCapability {
133
+ return collection.result.status === 'enumerated' || collection.items.length > 0
134
+ ? { status: 'available', limitations }
135
+ : { status: 'unverified', reason: 'No successful enumeration or entity observation established this reader capability.' };
136
+ }
137
+
138
+ private async collect<TItem extends { identity: plugins.fossInterfaces.data.TForgeInventoryIdentity }>(
139
+ read: (request: IForgeInventoryRequest) => Promise<plugins.fossInterfaces.data.IForgeInventoryPage<TItem>>,
140
+ maxItems: number, signal: AbortSignal, deadline: number, remainingBytes: () => number,
141
+ ): Promise<{ items: TItem[]; result: plugins.fossInterfaces.data.TForgeInventoryCollectionResult }> {
142
+ const startedAt = new Date().toISOString();
143
+ const items: TItem[] = [];
144
+ const identities = new Set<string>();
145
+ let cursor: string | undefined;
146
+ const incomplete = (reason: string) => ({ items, result: {
147
+ status: 'incomplete' as const, observation: this.#adapter.observation(startedAt), observedCount: String(items.length), reason,
148
+ } });
149
+ while (true) {
150
+ if (signal.aborted || Date.now() >= deadline) return incomplete('Scan aborted or deadline reached.');
151
+ if (remainingBytes() <= 0) return incomplete('Configured scan response-byte limit reached.');
152
+ let page: plugins.fossInterfaces.data.IForgeInventoryPage<TItem>;
153
+ try { page = await read({ cursor, signal, maxResponseBytes: remainingBytes() }); }
154
+ catch (error) {
155
+ return incomplete(error instanceof ForgeInventoryError ? error.message : 'Inventory request failed.');
156
+ }
157
+ for (const item of page.items) {
158
+ if (item.identity.status !== 'identified') return incomplete('Stable provider identity was not available.');
159
+ const key = JSON.stringify(item.identity.ref);
160
+ if (identities.has(key)) return incomplete('Repeated provider identity during live pagination.');
161
+ if (items.length >= maxItems) return incomplete('Configured item limit reached.');
162
+ identities.add(key); items.push(item);
163
+ }
164
+ if (signal.aborted || Date.now() >= deadline) return incomplete('Scan aborted or deadline reached.');
165
+ if (page.pagination.status === 'incomplete') return incomplete(page.pagination.reason);
166
+ if (page.pagination.status === 'exhausted') return {
167
+ items, result: { status: 'enumerated', observation: this.#adapter.observation(startedAt), count: String(items.length), sizeBytes: unknownSize('No byte measurement for this collection.') },
168
+ };
169
+ cursor = page.pagination.cursor;
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,88 @@
1
+ import type * as plugins from './plugins.js';
2
+
3
+ export class ForgeInventoryError extends Error {
4
+ constructor(public readonly code: string) {
5
+ // Codes are adapter-owned constants. Never include remote bodies or URLs.
6
+ super(`Forge inventory: ${code}`);
7
+ this.name = 'ForgeInventoryError';
8
+ }
9
+ }
10
+
11
+ export const record = (value: unknown): Record<string, unknown> => {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
13
+ throw new ForgeInventoryError('invalid-object');
14
+ }
15
+ return value as Record<string, unknown>;
16
+ };
17
+
18
+ export const decimal = (value: unknown): string => {
19
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
20
+ return String(value);
21
+ }
22
+ if (typeof value === 'string' && /^(0|[1-9][0-9]{0,99})$/.test(value)) {
23
+ return value;
24
+ }
25
+ throw new ForgeInventoryError('invalid-or-imprecise-integer');
26
+ };
27
+
28
+ export const optionalString = (value: unknown): string | null => {
29
+ if (value === undefined || value === null || value === '') return null;
30
+ if (typeof value !== 'string') throw new ForgeInventoryError('invalid-string');
31
+ return value;
32
+ };
33
+
34
+ export const optionalBoolean = (value: unknown): boolean | null => {
35
+ if (value === undefined || value === null) return null;
36
+ if (typeof value !== 'boolean') throw new ForgeInventoryError('invalid-boolean');
37
+ return value;
38
+ };
39
+
40
+ export const segments = (value: unknown): string[] => {
41
+ const path = optionalString(value);
42
+ if (!path || path.split('/').some((part) => !part || part === '.' || part === '..' || /[\\\u0000-\u001f\u007f]/.test(part))) {
43
+ throw new ForgeInventoryError('invalid-path');
44
+ }
45
+ // Provider JSON paths are already decoded. A literal hyphen is never a separator.
46
+ return path.split('/');
47
+ };
48
+
49
+ export const visibility = (value: unknown): plugins.fossInterfaces.data.TForgeInventoryVisibility | null => {
50
+ if (value === undefined || value === null) return null;
51
+ if (value === 'public' || value === 'internal' || value === 'private') return value;
52
+ throw new ForgeInventoryError('invalid-visibility');
53
+ };
54
+
55
+ export const httpUrl = (value: unknown): string | null => {
56
+ const text = optionalString(value);
57
+ if (text === null) return null;
58
+ try {
59
+ const url = new URL(text);
60
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error();
61
+ return url.href;
62
+ } catch {
63
+ throw new ForgeInventoryError('unsafe-http-url');
64
+ }
65
+ };
66
+
67
+ export const sshUrl = (value: unknown): string | null => {
68
+ const text = optionalString(value);
69
+ if (text === null) return null;
70
+ // Permit the transport username, but never a password, query, or fragment.
71
+ if (/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:[^\s?#]+$/.test(text)) return text;
72
+ try {
73
+ const url = new URL(text);
74
+ if (url.protocol !== 'ssh:' || url.password || url.search || url.hash) throw new Error();
75
+ return url.href;
76
+ } catch {
77
+ throw new ForgeInventoryError('unsafe-ssh-url');
78
+ }
79
+ };
80
+
81
+ export const unknownSize = (reason: string): plugins.fossInterfaces.data.TForgeInventoryQuantity => ({ status: 'unknown', reason });
82
+
83
+ export const gitlabSize = (statistics: unknown): plugins.fossInterfaces.data.TForgeInventoryQuantity => {
84
+ if (statistics === undefined || statistics === null) return unknownSize('Repository statistics were not exposed.');
85
+ const size = record(statistics).repository_size;
86
+ if (size === undefined || size === null) return unknownSize('Repository size was not exposed.');
87
+ return { status: 'measured', value: decimal(size), accuracy: 'estimate' };
88
+ };
package/ts/index.ts CHANGED
@@ -485,3 +485,5 @@ export class CodeFeed {
485
485
  throw new Error(`fetchFunction failed after retries for ${urlArg}: ${lastError?.message ?? 'unknown error'}`);
486
486
  }
487
487
  }
488
+ export * from './classes.forgeinventory.js';
489
+ export * from './classes.forgeinventoryscanner.js';
package/ts/plugins.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type * as fossInterfaces from '@foss.global/interfaces';
2
2
 
3
+ import * as crypto from 'node:crypto';
4
+ export { crypto };
5
+
3
6
  // module
4
7
  import * as interfaces from './interfaces/index.js';
5
8