SPB Git

Repository Public MIT

registry.ts 25.0 KB 439 lines typescript

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
Files main
Menu

Theme