Repository Public MIT
1import semver from "semver";2import { ARTIFACT_TYPES, CLIENTS, LIMITS, SLUG_RE, installInstructions, unsafePathReason, type ArtifactType, type Client, type Finding, type Manifest } from "@skills/registry-core";3import { audit } from "./audit";4import { getNamespace, hasScope, isPlatformAdmin, requireNamespaceWrite, requireScope, userCanPublishTo, type NamespaceRow } from "./auth";5import { userOf, type Ctx } from "./context";6import { ApiError, badRequest, conflict, forbidden, notFound } from "./errors";7import { env } from "./env";8import { exampleDtos, examplesWithSize } from "./media";910export interface ArtifactRow {11 id: string;12 namespace_id: string;13 namespace: string;14 slug: string;15 type: ArtifactType;16 summary: string;17 description: string | null;18 license: string | null;19 repository_url: string | null;20 homepage_url: string | null;21 issues_url: string | null;22 visibility: "public" | "unlisted" | "private";23 state: "draft" | "active" | "deprecated" | "archived" | "taken_down";24 deprecation_reason: string | null;25 capabilities: string[];26 keywords: string[];27 source_verified: boolean;28 latest_release_id: string | null;29 created_at: Date;30 updated_at: Date;31 // joined32 identity_verified?: boolean;33 owner_name?: string | null;34 latest_version?: string | null;35 latest_published_at?: Date | null;36 latest_security_status?: string | null;37 downloads_30d?: number;38}3940export interface ReleaseRow {41 id: string;42 artifact_id: string;43 version: string;44 manifest_json: Manifest;45 archive_object_key: string;46 sha256: string;47 size_bytes: string;48 unpacked_bytes: string;49 file_count: number;50 source_commit: string | null;51 release_notes: string | null;52 readme: string | null;53 published_by: string | null;54 published_at: Date;55 state: "published" | "quarantined" | "revoked";56 security_status: "passed" | "flagged" | "failed" | "not_scanned";57 revoked_at: Date | null;58 revocation_reason: string | null;59 publisher_name?: string | null;60}6162export function artifactDto(a: ArtifactRow) {63 return {64 id: a.id,65 name: `@${a.namespace}/${a.slug}`,66 namespace: a.namespace,67 slug: a.slug,68 type: a.type,69 summary: a.summary,70 license: a.license,71 repository: a.repository_url,72 homepage: a.homepage_url,73 issues: a.issues_url,74 visibility: a.visibility,75 state: a.state,76 deprecationReason: a.deprecation_reason,77 capabilities: a.capabilities,78 keywords: a.keywords,79 latestVersion: a.latest_version ?? null,80 latestPublishedAt: a.latest_published_at ?? null,81 latestSecurityStatus: a.latest_security_status ?? null,82 publisher: { namespace: a.namespace, displayName: a.owner_name ?? null, identityVerified: a.identity_verified ?? false },83 sourceVerified: a.source_verified,84 /** Estimate: deduplicated, bot-filtered downloads over the last 30 days. See /docs/security#download-counts. */85 downloads30d: a.downloads_30d ?? 0,86 createdAt: a.created_at,87 updatedAt: a.updated_at,88 url: `${env.publicUrl}/@${a.namespace}/${a.slug}`,89 };90}91export type ArtifactDto = ReturnType<typeof artifactDto>;9293export function releaseDto(a: Pick<ArtifactRow, "namespace" | "slug">, r: ReleaseRow) {94 return {95 id: r.id,96 version: r.version,97 state: r.state,98 securityStatus: r.security_status,99 sha256: r.sha256,100 sizeBytes: Number(r.size_bytes),101 unpackedBytes: Number(r.unpacked_bytes),102 fileCount: r.file_count,103 sourceCommit: r.source_commit,104 releaseNotes: r.release_notes,105 publishedAt: r.published_at,106 publishedBy: r.publisher_name ?? null,107 immutable: true,108 revokedAt: r.revoked_at,109 revocationReason: r.revocation_reason,110 downloadUrl: `${env.publicUrl}/api/v1/artifacts/${a.namespace}/${a.slug}/versions/${r.version}/download`,111 examples: exampleDtos(a, r),112 };113}114115const JOINED = (ctx: Ctx) => ctx.sql`116 a.*, n.identity_verified, coalesce(n.display_name, n.name::text) AS owner_name,117 lr.version AS latest_version, lr.published_at AS latest_published_at, lr.security_status AS latest_security_status,118 coalesce((SELECT sum(d.count)::int FROM download_daily d JOIN releases r2 ON r2.id = d.release_id119 WHERE r2.artifact_id = a.id AND d.day > current_date - 30), 0) AS downloads_30d`;120121/* ───────────────────────── reads ───────────────────────── */122123export async function canReadPrivate(ctx: Ctx, a: Pick<ArtifactRow, "namespace">): Promise<boolean> {124 const p = ctx.principal;125 const user = userOf(p);126 if (!user) return false;127 if (p.kind !== "token" && isPlatformAdmin(user)) return true;128 if (p.kind === "token" && p.token.elevated && hasScope(p, "admin:moderate") && isPlatformAdmin(user)) return true;129 if (p.kind === "token" && !(hasScope(p, "artifacts:read") && p.token.namespaces.includes(a.namespace))) return false;130 const ns = await getNamespace(ctx, a.namespace);131 return !!ns && userCanPublishTo(ctx, user, ns);132}133134export async function getArtifactRow(ctx: Ctx, namespace: string, slug: string): Promise<ArtifactRow> {135 if (!SLUG_RE.test(slug) || namespace.length > 40) throw notFound("Artifact");136 const [a] = await ctx.sql<ArtifactRow[]>`137 SELECT ${JOINED(ctx)} FROM artifacts a JOIN namespaces n ON n.id = a.namespace_id LEFT JOIN releases lr ON lr.id = a.latest_release_id138 WHERE a.namespace = ${namespace} AND a.slug = ${slug}`;139 if (!a) throw notFound("Artifact");140 const hidden = a.visibility === "private" || a.state === "draft" || a.state === "taken_down";141 if (hidden && !(await canReadPrivate(ctx, a))) {142 if (a.state === "taken_down") throw new ApiError(410, "taken_down", "This artifact was removed by registry moderators.");143 throw notFound("Artifact"); // do not reveal the existence of private artifacts144 }145 return a;146}147148export async function getArtifactById(ctx: Ctx, id: string): Promise<ArtifactRow> {149 if (!/^[0-9a-f-]{36}$/.test(id)) throw notFound("Artifact");150 const [a] = await ctx.sql<{ namespace: string; slug: string }[]>`SELECT namespace, slug FROM artifacts WHERE id = ${id}`;151 if (!a) throw notFound("Artifact");152 return getArtifactRow(ctx, a.namespace, a.slug);153}154155export interface ListQuery {156 q?: string;157 type?: string;158 keyword?: string;159 compatibility?: string;160 license?: string;161 namespace?: string;162 capability?: string;163 sort?: string;164 page?: number;165 perPage?: number;166 /** include the caller's non-public artifacts in a namespace they can read */167 includePrivate?: boolean;168}169170export async function listArtifacts(ctx: Ctx, query: ListQuery) {171 const sql = ctx.sql;172 const page = Math.min(Math.max(1, Math.floor(query.page ?? 1)), 1000);173 const perPage = Math.min(Math.max(1, Math.floor(query.perPage ?? 24)), 100);174 const q = (query.q ?? "").trim().slice(0, 200);175 if (query.type && !(ARTIFACT_TYPES as readonly string[]).includes(query.type)) throw badRequest(`Unknown type "${query.type}".`, { validTypes: ARTIFACT_TYPES });176 if (query.compatibility && !(CLIENTS as readonly string[]).includes(query.compatibility)) throw badRequest(`Unknown client "${query.compatibility}".`, { validClients: CLIENTS });177178 let visibility = sql`a.visibility = 'public' AND a.state IN ('active','deprecated')`;179 if (query.includePrivate && query.namespace && (await canReadPrivate(ctx, { namespace: query.namespace }))) visibility = sql`true`;180181 const terms = q.toLowerCase().match(/[\p{L}\p{N}]+/gu)?.slice(0, 8) ?? [];182 const prefix = terms.map((t) => `${t}:*`).join(" & ");183 const like = `%${q.replace(/[\\%_]/g, "\\$&")}%`;184 const haystack = sql`(a.namespace || '/' || a.slug || ' ' || a.summary)`;185 // Trigram matching is too noisy for very short queries; it only backs up typos in longer ones.186 const fuzzy = q.length >= 5;187 const match = q188 ? sql`AND (a.search @@ websearch_to_tsquery('english', ${q}) ${prefix ? sql`OR a.search @@ to_tsquery('simple', ${prefix})` : sql``}189 OR ${haystack} ILIKE ${like} ${fuzzy ? sql`OR word_similarity(${q}, ${haystack}) > 0.5` : sql``})`190 : sql``;191 const rank = q192 ? sql`(ts_rank_cd(a.search, websearch_to_tsquery('english', ${q})) * 2 ${prefix ? sql`+ ts_rank_cd(a.search, to_tsquery('simple', ${prefix}))` : sql``}193 + word_similarity(${q}, ${haystack}) + CASE WHEN a.slug = ${q.toLowerCase()} THEN 5 ELSE 0 END)`194 : sql`0`;195 const where = sql`196 ${visibility}197 ${query.type ? sql`AND a.type = ${query.type}` : sql``}198 ${query.namespace ? sql`AND a.namespace = ${query.namespace}` : sql``}199 ${query.license ? sql`AND a.license = ${query.license}` : sql``}200 ${query.keyword ? sql`AND ${query.keyword.toLowerCase()} = ANY(a.keywords)` : sql``}201 ${query.capability ? sql`AND ${query.capability} = ANY(a.capabilities)` : sql``}202 ${query.compatibility ? sql`AND EXISTS (SELECT 1 FROM release_compatibility rc WHERE rc.release_id = a.latest_release_id AND rc.client = ${query.compatibility})` : sql``}203 ${match}`;204 const sort = query.sort ?? (q ? "relevance" : "updated");205 const order =206 sort === "relevance" && q ? sql`rank DESC, a.updated_at DESC, a.id`207 : sort === "name" ? sql`a.namespace, a.slug, a.id`208 : sort === "downloads" ? sql`downloads_30d DESC, a.updated_at DESC, a.id`209 : sort === "created" ? sql`a.created_at DESC, a.id`210 : sql`a.updated_at DESC, a.id`;211212 const [cnt] = await sql<{ n: number }[]>`SELECT count(*)::int AS n FROM artifacts a WHERE ${where}`;213 const rows = await sql<ArtifactRow[]>`214 SELECT ${JOINED(ctx)}, ${rank} AS rank215 FROM artifacts a JOIN namespaces n ON n.id = a.namespace_id LEFT JOIN releases lr ON lr.id = a.latest_release_id216 WHERE ${where} ORDER BY ${order} LIMIT ${perPage} OFFSET ${(page - 1) * perPage}`;217 const total = cnt!.n;218 return { items: rows.map(artifactDto), page, perPage, total, totalPages: Math.max(1, Math.ceil(total / perPage)), query: { q, sort } };219}220221export async function facets(ctx: Ctx) {222 const pub = ctx.sql`visibility = 'public' AND state IN ('active','deprecated')`;223 const types = await ctx.sql<{ type: ArtifactType; n: number }[]>`SELECT type, count(*)::int AS n FROM artifacts WHERE ${pub} GROUP BY 1`;224 const licenses = await ctx.sql<{ license: string; n: number }[]>`SELECT license, count(*)::int AS n FROM artifacts WHERE ${pub} AND license IS NOT NULL GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 30`;225 const keywords = await ctx.sql<{ keyword: string; n: number }[]>`SELECT k AS keyword, count(*)::int AS n FROM artifacts, unnest(keywords) k WHERE ${pub} GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 40`;226 const [tot] = await ctx.sql<{ artifacts: number; releases: number; publishers: number }[]>`227 SELECT (SELECT count(*)::int FROM artifacts WHERE ${pub}) AS artifacts,228 (SELECT count(*)::int FROM releases r JOIN artifacts a ON a.id = r.artifact_id WHERE r.state = 'published' AND a.visibility = 'public' AND a.state IN ('active','deprecated')) AS releases,229 (SELECT count(DISTINCT namespace)::int FROM artifacts WHERE ${pub}) AS publishers`;230 return { types: Object.fromEntries(types.map((t) => [t.type, t.n])) as Partial<Record<ArtifactType, number>>, licenses, keywords, totals: tot! };231}232233export async function listReleases(ctx: Ctx, a: ArtifactRow): Promise<ReleaseRow[]> {234 const privileged = await canReadPrivate(ctx, a);235 return ctx.sql<ReleaseRow[]>`236 SELECT r.*, u.display_name AS publisher_name FROM releases r LEFT JOIN users u ON u.id = r.published_by237 WHERE r.artifact_id = ${a.id} ${privileged ? ctx.sql`` : ctx.sql`AND r.state <> 'quarantined'`}238 ORDER BY r.v_major DESC, r.v_minor DESC, r.v_patch DESC, (r.v_prerelease IS NULL) DESC, r.published_at DESC`;239}240241export async function getRelease(ctx: Ctx, a: ArtifactRow, version: string | undefined): Promise<ReleaseRow> {242 let v = version;243 if (!v || v === "latest") {244 if (!a.latest_version) throw notFound("Release");245 v = a.latest_version;246 }247 if (v.length > 64) throw notFound("Release");248 const [r] = await ctx.sql<ReleaseRow[]>`249 SELECT r.*, u.display_name AS publisher_name FROM releases r LEFT JOIN users u ON u.id = r.published_by WHERE r.artifact_id = ${a.id} AND r.version = ${v}`;250 if (!r) throw notFound("Release");251 if (r.state === "quarantined" && !(await canReadPrivate(ctx, a))) throw notFound("Release");252 return r;253}254255export interface FileRow {256 path: string;257 size_bytes: string;258 sha256: string;259 mime: string;260 mode: number;261 is_text: boolean;262 previewable: boolean;263}264export async function listFiles(ctx: Ctx, releaseId: string): Promise<FileRow[]> {265 return ctx.sql<FileRow[]>`SELECT path, size_bytes, sha256, mime, mode, is_text, content_text IS NOT NULL AS previewable FROM release_files WHERE release_id = ${releaseId} ORDER BY path COLLATE "C"`;266}267export const fileDto = (f: FileRow) => ({ path: f.path, sizeBytes: Number(f.size_bytes), sha256: f.sha256, mime: f.mime, executable: (f.mode & 0o111) !== 0, isText: f.is_text, previewable: f.previewable });268269/** Read one text file of a release. Lookup is by exact stored path; nothing is resolved on disk. */270export async function readReleaseFile(ctx: Ctx, releaseId: string, path: string) {271 if (typeof path !== "string" || unsafePathReason(path)) throw badRequest("Invalid file path.");272 const [f] = await ctx.sql<(FileRow & { content_text: string | null })[]>`273 SELECT path, size_bytes, sha256, mime, mode, is_text, content_text IS NOT NULL AS previewable, content_text FROM release_files WHERE release_id = ${releaseId} AND path = ${path}`;274 if (!f) throw notFound("File");275 if (f.content_text === null) throw new ApiError(415, "not_previewable", `This file is binary or larger than ${LIMITS.maxPreviewBytes} bytes; download the archive to inspect it.`);276 return { ...fileDto(f), content: f.content_text };277}278279export async function releaseDetail(ctx: Ctx, a: ArtifactRow, r: ReleaseRow) {280 const [files, deps, compat, scans] = await Promise.all([281 listFiles(ctx, r.id),282 ctx.sql<{ dep_namespace: string; dep_slug: string; version_range: string; optional: boolean }[]>`SELECT dep_namespace, dep_slug, version_range, optional FROM release_dependencies WHERE release_id = ${r.id} ORDER BY 1, 2`,283 ctx.sql<{ client: Client; platform: string; min_version: string | null; install_strategy: string; verified: boolean }[]>`SELECT client, platform, min_version, install_strategy, verified FROM release_compatibility WHERE release_id = ${r.id} ORDER BY client`,284 ctx.sql<{ scanner_version: string; findings: Finding[]; max_severity: string | null; completed_at: Date }[]>`SELECT scanner_version, findings, max_severity, completed_at FROM scan_results WHERE release_id = ${r.id} ORDER BY completed_at DESC LIMIT 1`,285 ]);286 const scan = scans[0];287 return {288 ...releaseDto(a, r),289 examples: examplesWithSize(a, r),290 manifest: r.manifest_json,291 readme: r.readme,292 files: files.map(fileDto),293 dependencies: deps.map((d) => ({ name: `@${d.dep_namespace}/${d.dep_slug}`, version: d.version_range, optional: d.optional })),294 compatibility: compat.map((c) => ({ client: c.client, platform: c.platform, minVersion: c.min_version, installStrategy: c.install_strategy, verified: c.verified })),295 scan: scan296 ? { scannerVersion: scan.scanner_version, completedAt: scan.completed_at, maxSeverity: scan.max_severity, findings: scan.findings, disclaimer: "Static checks are advisory. A release with no findings is not thereby safe." }297 : null,298 };299}300301export function instructionsFor(a: ArtifactRow, r: ReleaseRow, client?: string) {302 const t = { namespace: a.namespace, slug: a.slug, version: r.version, type: a.type, sha256: r.sha256, manifest: r.manifest_json, baseUrl: env.publicUrl };303 if (client) {304 if (!(CLIENTS as readonly string[]).includes(client)) throw badRequest(`Unknown client "${client}".`, { validClients: CLIENTS });305 return [installInstructions(t, client as Client)];306 }307 return CLIENTS.map((c) => installInstructions(t, c)).filter((i) => i.supported);308}309310/* ───────────────────────── mutations ───────────────────────── */311312const URL_OK = (u: unknown) => {313 if (u === null || u === undefined || u === "") return null;314 if (typeof u !== "string" || u.length > 500) throw badRequest("Links must be http(s) URLs of at most 500 characters.");315 let p: URL;316 try { p = new URL(u); } catch { throw badRequest("Links must be valid http(s) URLs."); }317 if (p.protocol !== "https:" && p.protocol !== "http:") throw badRequest("Links must be http(s) URLs.");318 return u;319};320321export interface CreateArtifactInput {322 namespace: string;323 slug: string;324 type: string;325 summary: string;326 visibility?: string;327 license?: string;328 repository?: string;329 homepage?: string;330 issues?: string;331 keywords?: string[];332 confirmPublic?: boolean;333}334335function cleanKeywords(k: unknown): string[] {336 if (k === undefined) return [];337 if (!Array.isArray(k) || k.length > 12 || k.some((x) => typeof x !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(x))) throw badRequest("keywords: up to 12 lowercase tags (letters, digits, hyphens).");338 return [...new Set(k as string[])];339}340function cleanSummary(s: unknown): string {341 if (typeof s !== "string" || s.trim().length < 10 || s.trim().length > LIMITS.maxSummaryLength) throw badRequest(`summary must be 10-${LIMITS.maxSummaryLength} characters.`);342 return s.trim();343}344345export async function createArtifact(ctx: Ctx, input: CreateArtifactInput): Promise<ArtifactRow> {346 const ns = await getNamespace(ctx, String(input.namespace ?? ""));347 if (!ns) {348 requireScope(ctx.principal, "artifacts:create");349 throw forbidden(`Namespace "${String(input.namespace).slice(0, 40)}" does not exist or is not yours.`, "namespace_forbidden");350 }351 await requireNamespaceWrite(ctx, ns, "artifacts:create");352 if (!SLUG_RE.test(String(input.slug))) throw badRequest("slug must be 1-64 lowercase letters, digits or single hyphens.");353 if (!(ARTIFACT_TYPES as readonly string[]).includes(input.type)) throw badRequest(`Unknown type "${input.type}".`, { validTypes: ARTIFACT_TYPES });354 const visibility = input.visibility ?? "private";355 if (!["public", "unlisted", "private"].includes(visibility)) throw badRequest("visibility must be public, unlisted or private.");356 if (visibility === "public" && input.confirmPublic !== true) throw new ApiError(428, "confirmation_required", "Creating a PUBLIC artifact requires explicit confirmation (confirmPublic: true).");357 const summary = cleanSummary(input.summary);358 const keywords = cleanKeywords(input.keywords);359 const [dup] = await ctx.sql`SELECT 1 FROM artifacts WHERE namespace_id = ${ns.id} AND slug = ${input.slug}`;360 if (dup) throw conflict(`@${ns.name}/${input.slug} already exists.`, "artifact_exists");361 const user = userOf(ctx.principal)!;362 const [row] = await ctx.sql<{ id: string }[]>`363 INSERT INTO artifacts (namespace_id, namespace, slug, type, summary, license, repository_url, homepage_url, issues_url, visibility, keywords, created_by)364 VALUES (${ns.id}, ${ns.name}, ${input.slug}, ${input.type}, ${summary}, ${input.license?.slice(0, 100) ?? null}, ${URL_OK(input.repository)}, ${URL_OK(input.homepage)},365 ${URL_OK(input.issues)}, ${visibility}, ${keywords}, ${user.id}) RETURNING id`;366 await audit(ctx, { action: "artifact.create", targetType: "artifact", targetId: row!.id, targetLabel: `@${ns.name}/${input.slug}`, detail: { type: input.type, visibility } });367 return getArtifactRow(ctx, ns.name, input.slug);368}369370async function writableArtifact(ctx: Ctx, id: string, scope: Parameters<typeof requireNamespaceWrite>[2]): Promise<{ a: ArtifactRow; ns: NamespaceRow }> {371 requireScope(ctx.principal, scope);372 if (!/^[0-9a-f-]{36}$/.test(id)) throw notFound("Artifact");373 const [raw] = await ctx.sql<{ namespace: string; slug: string }[]>`SELECT namespace, slug FROM artifacts WHERE id = ${id}`;374 if (!raw) throw notFound("Artifact");375 const ns = (await getNamespace(ctx, raw.namespace))!;376 await requireNamespaceWrite(ctx, ns, scope);377 return { a: await getArtifactRow(ctx, raw.namespace, raw.slug), ns };378}379export { writableArtifact };380381export async function updateArtifact(ctx: Ctx, id: string, patch: Record<string, unknown>): Promise<ArtifactRow> {382 const { a } = await writableArtifact(ctx, id, "artifacts:update");383 const allowed = ["summary", "description", "repository", "homepage", "issues", "keywords", "visibility", "confirmPublic"];384 const unknownKeys = Object.keys(patch).filter((k) => !allowed.includes(k));385 if (unknownKeys.length) throw badRequest(`These fields cannot be changed: ${unknownKeys.join(", ")}. Identity (namespace, slug, type) and release content are immutable.`);386 if (a.state === "taken_down") throw forbidden("This artifact was taken down by moderators and cannot be edited.");387 const next = {388 summary: patch.summary !== undefined ? cleanSummary(patch.summary) : a.summary,389 description: patch.description !== undefined ? (patch.description === null ? null : String(patch.description).slice(0, 200000)) : a.description,390 repository_url: patch.repository !== undefined ? URL_OK(patch.repository) : a.repository_url,391 homepage_url: patch.homepage !== undefined ? URL_OK(patch.homepage) : a.homepage_url,392 issues_url: patch.issues !== undefined ? URL_OK(patch.issues) : a.issues_url,393 keywords: patch.keywords !== undefined ? cleanKeywords(patch.keywords) : a.keywords,394 visibility: (patch.visibility as ArtifactRow["visibility"]) ?? a.visibility,395 };396 if (!["public", "unlisted", "private"].includes(next.visibility)) throw badRequest("visibility must be public, unlisted or private.");397 if (next.visibility !== a.visibility && next.visibility !== "private" && patch.confirmPublic !== true) {398 throw new ApiError(428, "confirmation_required", `Changing visibility from ${a.visibility} to ${next.visibility} exposes this artifact and requires explicit confirmation (confirmPublic: true).`);399 }400 const diff: Record<string, unknown> = {};401 if (next.summary !== a.summary) diff.summary = { from: a.summary, to: next.summary };402 if (next.visibility !== a.visibility) diff.visibility = { from: a.visibility, to: next.visibility };403 if (next.repository_url !== a.repository_url) diff.repository = { from: a.repository_url, to: next.repository_url };404 if (next.homepage_url !== a.homepage_url) diff.homepage = { from: a.homepage_url, to: next.homepage_url };405 if (next.issues_url !== a.issues_url) diff.issues = { from: a.issues_url, to: next.issues_url };406 if (JSON.stringify(next.keywords) !== JSON.stringify(a.keywords)) diff.keywords = { from: a.keywords, to: next.keywords };407 if (next.description !== a.description) diff.description = "changed";408 // A changed repository invalidates any earlier provenance verification.409 const sourceVerified = next.repository_url === a.repository_url ? a.source_verified : false;410 await ctx.sql`411 UPDATE artifacts SET summary = ${next.summary}, description = ${next.description}, repository_url = ${next.repository_url}, homepage_url = ${next.homepage_url},412 issues_url = ${next.issues_url}, keywords = ${next.keywords}, visibility = ${next.visibility}, source_verified = ${sourceVerified}, updated_at = now()413 WHERE id = ${a.id}`;414 await audit(ctx, { action: "artifact.update", targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}`, detail: { diff } });415 return getArtifactRow(ctx, a.namespace, a.slug);416}417418export async function setArtifactState(ctx: Ctx, id: string, action: "deprecate" | "undeprecate" | "archive", input: { reason?: string; confirm?: boolean }): Promise<ArtifactRow> {419 const { a } = await writableArtifact(ctx, id, "artifacts:deprecate");420 if (action !== "undeprecate" && input.confirm !== true) throw new ApiError(428, "confirmation_required", `To ${action} @${a.namespace}/${a.slug}, resend with confirm: true.`);421 if (a.state === "taken_down") throw forbidden("This artifact was taken down by moderators.");422 const reason = (input.reason ?? "").trim().slice(0, 1000);423 if (action === "deprecate" && reason.length < 5) throw badRequest("A deprecation reason (5+ characters) is required so users know what to use instead.");424 const state = action === "deprecate" ? "deprecated" : action === "archive" ? "archived" : a.latest_release_id ? "active" : "draft";425 await ctx.sql`UPDATE artifacts SET state = ${state}, deprecation_reason = ${action === "undeprecate" ? null : reason || a.deprecation_reason}, updated_at = now() WHERE id = ${a.id}`;426 await audit(ctx, { action: `artifact.${action}`, targetType: "artifact", targetId: a.id, targetLabel: `@${a.namespace}/${a.slug}`, detail: { from: a.state, to: state, reason } });427 return getArtifactRow(ctx, a.namespace, a.slug);428}429430/** Point the artifact at its best non-revoked release: highest stable version, else highest prerelease. */431export async function recomputeLatest(sql: Ctx["sql"], artifactId: string) {432 const rows = await sql<{ id: string; version: string }[]>`SELECT id, version FROM releases WHERE artifact_id = ${artifactId} AND state = 'published'`;433 const sorted = rows.sort((x, y) => semver.rcompare(x.version, y.version));434 const best = sorted.find((r) => semver.prerelease(r.version) === null) ?? sorted[0] ?? null;435 await sql`UPDATE artifacts SET latest_release_id = ${best?.id ?? null},436 state = CASE WHEN state = 'draft' AND ${best !== null} THEN 'active' WHEN state = 'active' AND ${best === null} THEN 'draft' ELSE state END,437 updated_at = now() WHERE id = ${artifactId}`;438}439