feat(registries): add exact-ID tag browser with non-401 failures (#1613)

* feat(resources): show multi-stack usedByStacks on images

Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change.

* feat(registries): add exact-ID tag browser with non-401 failures

Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet.

* fix(registries): distinguish unreachable hosts from auth failures

Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage.

* fix(registries): drop unused RegistryTagsPanel __test export

The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
This commit is contained in:
Anso
2026-07-11 13:21:38 -04:00
committed by GitHub
parent 362a18e91a
commit ce699864c1
12 changed files with 977 additions and 76 deletions
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
vi.mock('../services/registry-api', async () => {
const actual = await vi.importActual<typeof import('../services/registry-api')>('../services/registry-api');
return {
...actual,
listRegistryTagsResult: vi.fn(),
};
});
import { listRegistryTagsResult } from '../services/registry-api';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let RegistryService: typeof import('../services/RegistryService').RegistryService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ RegistryService } = await import('../services/RegistryService'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(listRegistryTagsResult).mockReset();
});
describe('GET /api/registries/:id/tags', () => {
it('returns tags for an exact registry id', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({ ok: true, tags: ['latest', '1.0'] });
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.tags).toEqual(['latest', '1.0']);
expect(res.body.registryId).toBe(id);
expect(listRegistryTagsResult).toHaveBeenCalled();
});
it('maps upstream unauthorized to 424 with REGISTRY_UNAUTHORIZED (never 401)', async () => {
const id = RegistryService.getInstance().create({
name: 'Hub2',
url: 'https://index.docker.io/v1/',
type: 'dockerhub',
username: 'user',
secret: 'bad',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UNAUTHORIZED',
message: 'Registry rejected credentials',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'library/nginx' })
.set('Authorization', authHeader);
expect(res.status).toBe(424);
expect(res.body.code).toBe('REGISTRY_UNAUTHORIZED');
expect(res.status).not.toBe(401);
});
it('rejects host-looking repository values', async () => {
const id = RegistryService.getInstance().create({
name: 'GHCR',
url: 'ghcr.io',
type: 'ghcr',
username: 'user',
secret: 'token',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'ghcr.io/org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(400);
expect(listRegistryTagsResult).not.toHaveBeenCalled();
});
it('maps REGISTRY_UPSTREAM from the client to HTTP 502', async () => {
const id = RegistryService.getInstance().create({
name: 'Down',
url: 'https://registry.example.invalid/',
type: 'custom',
username: 'user',
secret: 'token',
});
vi.mocked(listRegistryTagsResult).mockResolvedValue({
ok: false,
code: 'REGISTRY_UPSTREAM',
message: 'Registry unreachable',
});
const res = await request(app)
.get(`/api/registries/${id}/tags`)
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(502);
expect(res.body.code).toBe('REGISTRY_UPSTREAM');
});
it('returns 404 for unknown registry id', async () => {
const res = await request(app)
.get('/api/registries/999999/tags')
.query({ repository: 'org/app' })
.set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});
+83 -1
View File
@@ -39,7 +39,7 @@ function fakeRequest(url: string, options: { method?: string }, cb: (res: EventE
vi.mock('https', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
vi.mock('http', () => ({ default: { request: (...args: unknown[]) => fakeRequest(...(args as Parameters<typeof fakeRequest>)) } }));
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, parseImageRef } from '../services/registry-api';
import { repoDigestMatchesRef, getRemoteDigest, getRemoteDigestResult, getAuthToken, listRegistryTagsResult, parseImageRef } from '../services/registry-api';
const TOKEN_BODY = JSON.stringify({ token: 'test-token' });
const REMOTE = 'sha256:remote000000000000000000000000000000000000000000000000000000';
@@ -234,4 +234,86 @@ describe('getRemoteDigestResult failure reasons', () => {
route = () => { throw new Error('ENOTFOUND'); };
expect(await get()).toEqual({ ok: false, reason: `Registry unreachable for ${REF} (ENOTFOUND)` });
});
it('falls back to anonymous manifest lookup when auth transport fails', async () => {
route = (url, method): FakeResp => {
if (url.includes('auth.docker.io/token')) {
throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
}
if (method === 'HEAD') return { statusCode: 200, headers: { 'docker-content-digest': REMOTE } };
return { statusCode: 500, headers: {} };
};
expect(await get()).toEqual({ ok: true, digest: REMOTE });
});
});
describe('listRegistryTagsResult', () => {
const creds = { username: 'u', password: 'p' };
const GHCR_CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"';
beforeEach(() => {
calls.length = 0;
});
function authThenTags(tagResp: FakeResp): (url: string, method: string) => FakeResp {
return (url) => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 200, headers: {}, body: TOKEN_BODY };
if (url.includes('/tags/list')) return tagResp;
return { statusCode: 500, headers: {} };
};
}
async function expectFailure(code: string, message?: string): Promise<void> {
const result = await listRegistryTagsResult('ghcr.io', 'acme/app', creds);
expect(result).toMatchObject(message ? { ok: false, code, message } : { ok: false, code });
}
it('returns tags on a successful list', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['latest', '1.0'] }) });
await expect(listRegistryTagsResult('ghcr.io', 'acme/app', creds)).resolves.toEqual({
ok: true,
tags: ['latest', '1.0'],
});
});
it('maps transport failure during auth ping to REGISTRY_UPSTREAM (not UNAUTHORIZED)', async () => {
route = () => { throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); };
await expectFailure('REGISTRY_UPSTREAM', 'Registry unreachable');
});
it('maps a rejected token to REGISTRY_UNAUTHORIZED', async () => {
route = (url): FakeResp => {
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': GHCR_CHALLENGE } };
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} };
return { statusCode: 200, headers: {}, body: '{}' };
};
await expectFailure('REGISTRY_UNAUTHORIZED', 'Registry rejected credentials');
});
it('maps 403 on tags/list to REGISTRY_FORBIDDEN', async () => {
route = authThenTags({ statusCode: 403, headers: {} });
await expectFailure('REGISTRY_FORBIDDEN');
});
it('maps 429 on tags/list to REGISTRY_RATE_LIMITED', async () => {
route = authThenTags({ statusCode: 429, headers: {} });
await expectFailure('REGISTRY_RATE_LIMITED');
});
it('maps invalid JSON to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: 'not-json' });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry returned invalid JSON');
});
it('maps a non-array tags field to REGISTRY_INVALID_RESPONSE', async () => {
route = authThenTags({ statusCode: 200, headers: {}, body: JSON.stringify({ tags: 'latest' }) });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list was malformed');
});
it('maps an oversized body to REGISTRY_INVALID_RESPONSE', async () => {
const huge = '{"tags":["' + 'x'.repeat(2 * 1024 * 1024) + '"]}';
route = authThenTags({ statusCode: 200, headers: {}, body: huge });
await expectFailure('REGISTRY_INVALID_RESPONSE', 'Registry tag list response too large');
});
});
+110
View File
@@ -1,8 +1,10 @@
import { Router, type Request, type Response } from 'express';
import { RegistryService } from '../services/RegistryService';
import { listRegistryTagsResult, type TagListCode } from '../services/registry-api';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
const VALID_REGISTRY_TYPES = ['dockerhub', 'ghcr', 'ecr', 'custom'] as const;
const REGISTRY_SCOPE_MESSAGE = 'API tokens cannot manage registry credentials.';
@@ -30,6 +32,40 @@ function allowRegistryType(type: string | undefined, req: Request, res: Response
return true;
}
const REPO_MAX_LEN = 255;
const REPO_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/i;
/** Path-safe repository name only (no scheme, host, or tag). */
function parseRepositoryParam(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const repo = raw.trim();
if (!repo || repo.length > REPO_MAX_LEN) return null;
if (repo.includes('://') || repo.includes('@') || repo.startsWith('/') || repo.includes(':')) return null;
const first = repo.split('/')[0];
// Host-looking first segment (e.g. ghcr.io/org/name) is rejected; host comes from the registry row.
if (first.includes('.')) return null;
if (!REPO_PATTERN.test(repo)) return null;
return repo;
}
function tagListHttpStatus(code: TagListCode): number {
switch (code) {
case 'REGISTRY_UNAUTHORIZED':
case 'REGISTRY_FORBIDDEN':
case 'REGISTRY_RATE_LIMITED':
case 'REGISTRY_UNSUPPORTED':
return 424;
case 'REGISTRY_NOT_FOUND':
return 404;
case 'REGISTRY_INVALID_RESPONSE':
return 400;
case 'REGISTRY_UPSTREAM':
default:
return 502;
}
}
export const registriesRouter = Router();
registriesRouter.get('/', (req: Request, res: Response): void => {
@@ -136,6 +172,80 @@ registriesRouter.delete('/:id', (req: Request, res: Response): void => {
}
});
// Browse tags for a configured registry + validated repository (hub-only).
// Upstream registry auth failures map to 424/502 — never HTTP 401 (that would
// log the browser session out via the frontend unauthorized handler).
registriesRouter.get('/:id/tags', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
try {
const id = parseIntParam(req, res, 'id', 'registry ID');
if (id === null) return;
const repository = parseRepositoryParam(req.query.repository);
if (!repository) {
res.status(400).json({ error: 'Query parameter repository is required and must be a path-safe repository name' });
return;
}
const limitRaw = req.query.limit;
let limit = 50;
if (limitRaw !== undefined) {
const n = typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : NaN;
if (!Number.isFinite(n) || n < 1 || n > 100) {
res.status(400).json({ error: 'limit must be an integer from 1 to 100' });
return;
}
limit = n;
}
const cursor = typeof req.query.cursor === 'string' && req.query.cursor.trim()
? req.query.cursor.trim()
: undefined;
const auth = await RegistryService.getInstance().getAuthForRegistryId(id);
if (!auth.ok) {
if (auth.code === 'missing') {
res.status(404).json({ error: auth.message, code: 'REGISTRY_NOT_FOUND' });
return;
}
const code = auth.code === 'ecr_failed' || auth.code === 'decrypt_failed'
? 'REGISTRY_UNAUTHORIZED'
: 'REGISTRY_UPSTREAM';
res.status(424).json({ error: auth.message, code });
return;
}
if (!allowRegistryType(auth.type, req, res)) return;
// Docker Hub official images are often referenced as library/<name>.
let repo = repository;
if (auth.type === 'dockerhub' && !repo.includes('/')) {
repo = `library/${repo}`;
}
const result = await listRegistryTagsResult(
auth.registryHost,
repo,
{ username: auth.username, password: auth.password },
{ limit, cursor },
);
if (!result.ok) {
res.status(tagListHttpStatus(result.code)).json({ error: result.message, code: result.code });
return;
}
res.json({
tags: result.tags,
nextCursor: result.nextCursor ?? null,
registryId: id,
registryName: auth.name,
repository: repo,
});
} catch (error) {
console.error('[Registries] Tag list error:', sanitizeForLog(error instanceof Error ? error.message : String(error)));
res.status(500).json({ error: 'Failed to list registry tags' });
}
});
registriesRouter.post('/:id/test', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, REGISTRY_SCOPE_MESSAGE)) return;
if (!requireAdmin(req, res)) return;
+62 -1
View File
@@ -42,6 +42,23 @@ export interface ResolvedDockerConfig {
warnings: string[];
}
export type RegistryAuthForIdResult =
| {
ok: true;
username: string;
password: string;
registryHost: string;
type: RegistryType;
name: string;
}
| { ok: false; code: 'missing' | 'decrypt_failed' | 'ecr_failed'; message: string };
/** Host used for Docker Registry HTTP API calls (/v2/...). */
export function registryApiHost(reg: Pick<Registry, 'url' | 'type'>): string {
if (reg.type === 'dockerhub') return 'registry-1.docker.io';
return hostFromStoredRegistry(reg);
}
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
@@ -83,7 +100,7 @@ function toProbeUrl(url: string, type: RegistryType): string {
}
/** Canonical host for matching (image ref → stored credential). */
function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
export function hostFromStoredRegistry(reg: Pick<Registry, 'url' | 'type'>): string {
if (reg.type === 'dockerhub') return 'index.docker.io';
try {
const withProtocol = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
@@ -375,6 +392,50 @@ export class RegistryService {
return { config: { auths }, warnings };
}
/**
* Resolve credentials for a specific registry row by ID only.
* Never falls back to host-based matching (duplicate hosts must use the requested ID).
*/
public async getAuthForRegistryId(id: number): Promise<RegistryAuthForIdResult> {
const reg = DatabaseService.getInstance().getRegistry(id);
if (!reg) {
return { ok: false, code: 'missing', message: 'Registry not found' };
}
try {
const registryHost = registryApiHost(reg);
if (reg.type === 'ecr') {
const creds = await this.getEcrCredentials(reg);
return {
ok: true,
username: creds.username,
password: creds.password,
registryHost,
type: reg.type,
name: reg.name,
};
}
return {
ok: true,
username: reg.username,
password: this.crypto.decrypt(reg.secret),
registryHost,
type: reg.type,
name: reg.name,
};
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
console.warn(
`[RegistryService] getAuthForRegistryId(${id}) failed:`,
sanitizeForLog(detail),
);
if (reg.type === 'ecr') {
return { ok: false, code: 'ecr_failed', message: 'Failed to refresh ECR credentials' };
}
return { ok: false, code: 'decrypt_failed', message: 'Failed to decrypt registry credentials' };
}
}
// ─── Registry auth for ImageUpdateService ────────────────────────────────
public async getAuthForRegistry(registryHost: string): Promise<{ username: string; password: string } | null> {
+145 -46
View File
@@ -98,39 +98,41 @@ export async function getAuthToken(
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string | null> {
// Transport errors propagate (callers map to REGISTRY_UPSTREAM). null = auth/token failure only.
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
// The /v2/ ping carries no repository context, so any scope it echoes is a
// placeholder (ghcr.io returns repository:user/image:pull). Always request
// the scope for the repository we actually want; reusing the echoed scope
// makes ghcr.io mint a token for the wrong repo and then reject the pull.
params.set('scope', `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
try {
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
// The /v2/ ping carries no repository context, so any scope it echoes is a
// placeholder (ghcr.io returns repository:user/image:pull). Always request
// the scope for the repository we actually want; reusing the echoed scope
// makes ghcr.io mint a token for the wrong repo and then reject the pull.
params.set('scope', `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
return parsed.token ?? parsed.access_token ?? null;
const parsed = JSON.parse(tokenRes.body) as { token?: unknown; access_token?: unknown };
const token = parsed.token ?? parsed.access_token;
return typeof token === 'string' ? token : null;
} catch {
return null;
}
@@ -210,7 +212,21 @@ export async function getRemoteDigestResult(
): Promise<RemoteDigestResult> {
const ref = `${registry}/${repo}:${tag}`;
try {
const token = await getAuthToken(registry, repo, credentials);
// Auth transport failures used to collapse to null inside getAuthToken.
// Tag listing now needs those errors to propagate (REGISTRY_UPSTREAM), so
// digest lookup keeps anonymous fallback here when the token endpoint is down.
let token: string | null = null;
try {
token = await getAuthToken(registry, repo, credentials);
} catch (authErr) {
const cause = authErr instanceof Error
? ((authErr as NodeJS.ErrnoException).code ?? authErr.message)
: String(authErr);
console.error(
`[registry-api] Auth for ${sanitizeForLog(ref)} failed; trying anonymous:`,
sanitizeForLog(cause),
);
}
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = `https://${registry}/v2/${repo}/manifests/${tag}`;
@@ -260,22 +276,105 @@ export async function getRemoteDigest(
return result.ok ? result.digest : null;
}
export type TagListCode =
| 'REGISTRY_UNAUTHORIZED'
| 'REGISTRY_FORBIDDEN'
| 'REGISTRY_NOT_FOUND'
| 'REGISTRY_RATE_LIMITED'
| 'REGISTRY_UNSUPPORTED'
| 'REGISTRY_UPSTREAM'
| 'REGISTRY_INVALID_RESPONSE';
export type TagListResult =
| { ok: true; tags: string[]; nextCursor?: string }
| { ok: false; code: TagListCode; message: string };
const TAG_LIST_BODY_CAP = 2 * 1024 * 1024; // 2 MiB
function tagListFailure(statusCode: number): TagListResult {
if (statusCode === 401) {
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
}
if (statusCode === 403) {
return { ok: false, code: 'REGISTRY_FORBIDDEN', message: 'Registry denied access to this repository' };
}
if (statusCode === 404) {
return { ok: false, code: 'REGISTRY_NOT_FOUND', message: 'Repository not found on registry' };
}
if (statusCode === 429) {
return { ok: false, code: 'REGISTRY_RATE_LIMITED', message: 'Registry rate limit exceeded' };
}
if (statusCode >= 500) {
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry error (${statusCode})` };
}
return { ok: false, code: 'REGISTRY_UPSTREAM', message: `Registry returned status ${statusCode}` };
}
function parseNextCursor(linkHeader: string | string[] | undefined): string | undefined {
const raw = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader;
if (!raw) return undefined;
// Rel=next Link: </v2/repo/tags/list?n=50&last=foo>; rel="next"
const match = raw.match(/<[^>]*[?&]last=([^&>]+)[^>]*>\s*;\s*rel="?next"?/i);
if (!match) return undefined;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
/**
* Typed tag list for the Resources registry browser. Never collapses auth
* failures into an empty array (that would hide credential problems).
*/
export async function listRegistryTagsResult(
registry: string,
repo: string,
credentials: RegistryCredentials,
opts: { limit?: number; cursor?: string } = {},
): Promise<TagListResult> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
try {
const token = await getAuthToken(registry, repo, credentials);
if (!token) {
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
}
const headers: Record<string, string> = { Accept: 'application/json', Authorization: `Bearer ${token}` };
const params = new URLSearchParams({ n: String(limit) });
if (opts.cursor) params.set('last', opts.cursor);
const url = `https://${registry}/v2/${repo}/tags/list?${params.toString()}`;
const res = await httpGet(url, headers);
if (res.statusCode !== 200) return tagListFailure(res.statusCode);
if (res.body.length > TAG_LIST_BODY_CAP) {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list response too large' };
}
let parsed: { tags?: unknown };
try {
parsed = JSON.parse(res.body) as { tags?: unknown };
} catch {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry returned invalid JSON' };
}
if (!Array.isArray(parsed.tags) || !parsed.tags.every((t) => typeof t === 'string')) {
return { ok: false, code: 'REGISTRY_INVALID_RESPONSE', message: 'Registry tag list was malformed' };
}
const nextCursor = parseNextCursor(res.headers['link']);
return nextCursor
? { ok: true, tags: parsed.tags as string[], nextCursor }
: { ok: true, tags: parsed.tags as string[] };
} catch (e) {
const cause = e instanceof Error ? ((e as NodeJS.ErrnoException).code ?? e.message) : String(e);
console.error('[registry-api] Tag list failed:', sanitizeForLog(cause));
return { ok: false, code: 'REGISTRY_UPSTREAM', message: 'Registry unreachable' };
}
}
/** Compatibility wrapper for update-preview: empty list on any failure. */
export async function listRegistryTags(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string[]> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers);
if (res.statusCode !== 200) return [];
const parsed = JSON.parse(res.body) as { tags?: string[] };
return Array.isArray(parsed.tags) ? parsed.tags : [];
} catch {
return [];
}
if (!credentials) return [];
const result = await listRegistryTagsResult(registry, repo, credentials);
return result.ok ? result.tags : [];
}