SPB Git

Repository Public MIT

api.ts 33.0 KB 388 lines typescript

1import { createHash, randomUUID } from "node:crypto";2import { ALL_SCOPES, ARTIFACT_TYPES, CAPABILITIES, CLIENTS, LIMITS, SCOPES, TYPE_LABELS, manifestSchema, type Scope } from "@skills/registry-core";3import { audit } from "./audit";4import {5  SESSION_COOKIE, createToken, listTokens, listWritableNamespaces, principalFromBearer, principalFromSession, publicToken, requireUser, revokeToken, rotateToken,6} from "./auth";7import { artifactHistory, createReport, collectionDto, getCollection, listAudit, listCollections, listQuarantined, listReports, moderateArtifact, resolveReport } from "./community";8import { clientIp, makeCtx, userOf, type Ctx, type Principal } from "./context";9import { csrfTokenFor } from "./crypto";10import { db } from "./db";11import { env } from "./env";12import { ApiError, badRequest, forbidden, notFound, unauthorized } from "./errors";13import { serveExampleMedia } from "./media";14import { beginUpload, downloadRelease, finalizeUpload, getUpload, receiveUpload, reviewRelease, revokeRelease, updateReleaseNotes } from "./publish";15import { rateLimit, type Bucket } from "./ratelimit";16import {17  artifactDto, createArtifact, facets, getArtifactById, getArtifactRow, getRelease, instructionsFor, listArtifacts, listReleases, readReleaseFile, releaseDetail, releaseDto,18  setArtifactState, updateArtifact,19} from "./registry";20import { storageWritable } from "./storage";2122export const API_VERSION = "1.0.0";2324interface RouteCtx {25  ctx: Ctx;26  req: Request;27  params: Record<string, string>;28  query: URLSearchParams;29  body: () => Promise<Record<string, unknown>>;30}31interface Route {32  method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";33  path: string;34  summary: string;35  tag: string;36  auth: "public" | "required";37  scope?: Scope;38  bucket?: Bucket;39  idempotent?: boolean;40  query?: Record<string, string>;41  bodySchema?: Record<string, unknown>;42  handler: (r: RouteCtx) => Promise<unknown | Response>;43  re?: RegExp;44  keys?: string[];45}4647const num = (v: string | null) => (v === null || v === "" ? undefined : Number.isFinite(Number(v)) ? Number(v) : undefined);48const str = (v: unknown) => (typeof v === "string" ? v : undefined);4950async function artifactAndRelease(r: RouteCtx) {51  const a = await getArtifactRow(r.ctx, r.params.namespace!, r.params.slug!);52  const rel = await getRelease(r.ctx, a, r.params.version);53  return { a, rel };54}5556export const routes: Route[] = [57  { method: "GET", path: "/health/live", tag: "Health", summary: "Liveness probe", auth: "public", handler: async () => ({ status: "ok" }) },58  {59    method: "GET", path: "/health/ready", tag: "Health", summary: "Readiness probe (database and storage)", auth: "public",60    handler: async ({ ctx }) => {61      let database = false;62      try { await ctx.sql`SELECT 1`; database = true; } catch { /* reported below */ }63      const storage = storageWritable();64      if (!database || !storage) return Response.json({ status: "unavailable", database, storage }, { status: 503 });65      return { status: "ready", database, storage, apiVersion: API_VERSION };66    },67  },68  {69    method: "GET", path: "/artifacts", tag: "Discovery", summary: "List and filter public artifacts", auth: "public", bucket: "anon-read",70    query: { q: "Full-text query", type: `One of: ${ARTIFACT_TYPES.join(", ")}`, keyword: "Exact keyword", compatibility: `One of: ${CLIENTS.join(", ")}`, license: "Exact licence id", namespace: "Publisher namespace", capability: `One of: ${CAPABILITIES.join(", ")}`, sort: "relevance | updated | created | name | downloads", page: "1-based page", per_page: "1-100 (default 24)", include_private: "true to include your own non-public artifacts (requires namespace and artifacts:read)" },71    handler: async ({ ctx, query }) => listArtifacts(ctx, {72      q: query.get("q") ?? undefined, type: query.get("type") ?? undefined, keyword: query.get("keyword") ?? undefined, compatibility: query.get("compatibility") ?? undefined,73      license: query.get("license") ?? undefined, namespace: query.get("namespace") ?? undefined, capability: query.get("capability") ?? undefined, sort: query.get("sort") ?? undefined,74      page: num(query.get("page")), perPage: num(query.get("per_page")), includePrivate: query.get("include_private") === "true",75    }),76  },77  {78    method: "GET", path: "/search", tag: "Discovery", summary: "Full-text search (PostgreSQL tsvector + trigram)", auth: "public", bucket: "search",79    query: { q: "Query (required)", type: "Artifact type", compatibility: "Client id", page: "Page", per_page: "Page size" },80    handler: async ({ ctx, query }) => {81      const q = (query.get("q") ?? "").trim();82      if (!q) throw badRequest("q is required.");83      return listArtifacts(ctx, { q, type: query.get("type") ?? undefined, compatibility: query.get("compatibility") ?? undefined, keyword: query.get("keyword") ?? undefined, page: num(query.get("page")), perPage: num(query.get("per_page")), sort: "relevance" });84    },85  },86  {87    method: "GET", path: "/categories", tag: "Discovery", summary: "Artifact types with live counts, licences and keywords", auth: "public", bucket: "anon-read",88    handler: async ({ ctx }) => {89      const f = await facets(ctx);90      return { types: ARTIFACT_TYPES.map((t) => ({ type: t, label: TYPE_LABELS[t].plural, description: TYPE_LABELS[t].blurb, count: f.types[t] ?? 0 })), licenses: f.licenses, keywords: f.keywords, totals: f.totals };91    },92  },93  { method: "GET", path: "/collections", tag: "Discovery", summary: "Curated collections", auth: "public", bucket: "anon-read", query: { category: "Filter by category" }, handler: async ({ ctx, query }) => ({ items: (await listCollections(ctx, query.get("category") ?? undefined)).map(collectionDto) }) },94  { method: "GET", path: "/collections/:slug", tag: "Discovery", summary: "One collection with its artifacts", auth: "public", bucket: "anon-read", handler: async ({ ctx, params }) => getCollection(ctx, params.slug!) },95  {96    method: "GET", path: "/artifacts/:namespace/:slug", tag: "Artifacts", summary: "Artifact metadata and latest release", auth: "public", bucket: "anon-read",97    handler: async ({ ctx, params }) => {98      const a = await getArtifactRow(ctx, params.namespace!, params.slug!);99      const latest = a.latest_version ? await getRelease(ctx, a, a.latest_version) : null;100      return { ...artifactDto(a), description: a.description, latestRelease: latest ? releaseDto(a, latest) : null };101    },102  },103  { method: "GET", path: "/artifacts/:namespace/:slug/versions", tag: "Artifacts", summary: "Release history", auth: "public", bucket: "anon-read", handler: async ({ ctx, params }) => { const a = await getArtifactRow(ctx, params.namespace!, params.slug!); return { items: (await listReleases(ctx, a)).map((r) => releaseDto(a, r)) }; } },104  { method: "GET", path: "/artifacts/:namespace/:slug/history", tag: "Artifacts", summary: "Public publication history (who published or revoked what, when)", auth: "public", bucket: "anon-read", handler: async ({ ctx, params }) => ({ items: await artifactHistory(ctx, await getArtifactRow(ctx, params.namespace!, params.slug!)) }) },105  { method: "GET", path: "/artifacts/:namespace/:slug/versions/:version", tag: "Artifacts", summary: "Release detail: manifest, files, checksums, dependencies, compatibility, scan findings", auth: "public", bucket: "anon-read", handler: async (r) => { const { a, rel } = await artifactAndRelease(r); return releaseDetail(r.ctx, a, rel); } },106  { method: "GET", path: "/artifacts/:namespace/:slug/versions/:version/download", tag: "Artifacts", summary: "Download the immutable release archive (application/gzip)", auth: "public", bucket: "download", handler: async (r) => { const { a, rel } = await artifactAndRelease(r); return downloadRelease(r.ctx, a, rel, r.req.headers.get("if-none-match")); } },107  {108    method: "GET", path: "/artifacts/:namespace/:slug/versions/:version/checksum", tag: "Artifacts", summary: "SHA-256 in `shasum -c` format (text/plain)", auth: "public", bucket: "anon-read",109    handler: async (r) => { const { a, rel } = await artifactAndRelease(r); return new Response(`${rel.sha256}  ${a.slug}-${rel.version}.tar.gz\n`, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); },110  },111  {112    method: "GET", path: "/artifacts/:namespace/:slug/versions/:version/files", tag: "Artifacts", summary: "Read one text file of a release (safe path lookup)", auth: "public", bucket: "anon-read", query: { path: "Relative path inside the release (required)" },113    handler: async (r) => { const { rel } = await artifactAndRelease(r); return readReleaseFile(r.ctx, rel.id, r.query.get("path") ?? ""); },114  },115  {116    method: "GET", path: "/artifacts/:namespace/:slug/versions/:version/media/*path", tag: "Artifacts", summary: "One usage-example image of a release (PNG, JPEG or WebP). Only paths listed in the release manifest `examples` are served", auth: "public", bucket: "media",117    handler: async (r) => { const { a, rel } = await artifactAndRelease(r); return serveExampleMedia(a, rel, r.params.path!, r.req.headers.get("if-none-match")); },118  },119  {120    method: "GET", path: "/artifacts/:namespace/:slug/versions/:version/install", tag: "Artifacts", summary: "Client-specific installation instructions", auth: "public", bucket: "anon-read", query: { client: `One of: ${CLIENTS.join(", ")}` },121    handler: async (r) => { const { a, rel } = await artifactAndRelease(r); return { artifact: `@${a.namespace}/${a.slug}`, version: rel.version, sha256: rel.sha256, instructions: instructionsFor(a, rel, r.query.get("client") ?? undefined) }; },122  },123  {124    method: "POST", path: "/artifacts", tag: "Publishing", summary: "Create an artifact in a namespace you own", auth: "required", scope: "artifacts:create", bucket: "token-write", idempotent: true,125    bodySchema: { type: "object", required: ["namespace", "slug", "type", "summary"], properties: { namespace: { type: "string" }, slug: { type: "string" }, type: { enum: ARTIFACT_TYPES }, summary: { type: "string" }, visibility: { enum: ["public", "unlisted", "private"], default: "private" }, confirmPublic: { type: "boolean", description: "Required when visibility is public" }, license: { type: "string" }, repository: { type: "string" }, homepage: { type: "string" }, issues: { type: "string" }, keywords: { type: "array", items: { type: "string" } } } },126    handler: async ({ ctx, body }) => { const a = await createArtifact(ctx, (await body()) as never); return Response.json(artifactDto(a), { status: 201 }); },127  },128  { method: "GET", path: "/artifacts/:id", tag: "Artifacts", summary: "Artifact by id", auth: "public", bucket: "anon-read", handler: async ({ ctx, params }) => artifactDto(await getArtifactById(ctx, params.id!)) },129  {130    method: "PATCH", path: "/artifacts/:id", tag: "Publishing", summary: "Update mutable artifact metadata", auth: "required", scope: "artifacts:update", bucket: "token-write",131    bodySchema: { type: "object", properties: { summary: { type: "string" }, description: { type: ["string", "null"] }, repository: { type: ["string", "null"] }, homepage: { type: ["string", "null"] }, issues: { type: ["string", "null"] }, keywords: { type: "array", items: { type: "string" } }, visibility: { enum: ["public", "unlisted", "private"] }, confirmPublic: { type: "boolean" } } },132    handler: async ({ ctx, params, body }) => artifactDto(await updateArtifact(ctx, params.id!, await body())),133  },134  {135    method: "POST", path: "/artifacts/:id/releases", tag: "Publishing", summary: "Begin a release upload (step 1 of 3)", auth: "required", scope: "uploads:create", bucket: "upload", idempotent: true,136    bodySchema: { type: "object", required: ["version", "sha256", "sizeBytes"], properties: { version: { type: "string" }, sha256: { type: "string", pattern: "^[0-9a-f]{64}$" }, sizeBytes: { type: "integer", maximum: LIMITS.maxArchiveBytes }, releaseNotes: { type: "string" }, sourceCommit: { type: "string" } } },137    handler: async ({ ctx, params, body }) => Response.json(await beginUpload(ctx, params.id!, (await body()) as never), { status: 201 }),138  },139  { method: "GET", path: "/uploads/:id", tag: "Publishing", summary: "Upload session status", auth: "required", scope: "uploads:create", bucket: "token-write", handler: async ({ ctx, params }) => getUpload(ctx, params.id!) },140  {141    method: "PUT", path: "/uploads/:id", tag: "Publishing", summary: "Send the archive bytes (step 2 of 3, application/gzip)", auth: "required", scope: "uploads:create", bucket: "upload",142    handler: async ({ ctx, params, req }) => receiveUpload(ctx, params.id!, req.body, num(req.headers.get("content-length")) ?? null),143  },144  {145    method: "POST", path: "/uploads/:id/finalize", tag: "Publishing", summary: "Validate, scan and publish as an immutable release (step 3 of 3)", auth: "required", scope: "releases:publish", bucket: "publish", idempotent: true,146    bodySchema: { type: "object", required: ["sha256"], properties: { sha256: { type: "string", pattern: "^[0-9a-f]{64}$" } } },147    handler: async ({ ctx, params, body }) => { const b = await body(); const out = await finalizeUpload(ctx, params.id!, { sha256: String(b.sha256 ?? "") }); return Response.json(out, { status: out.replayed ? 200 : 201 }); },148  },149  ...(["deprecate", "undeprecate", "archive"] as const).map((action): Route => ({150    method: "POST", path: `/artifacts/:id/${action}`, tag: "Publishing", summary: `${action[0]!.toUpperCase()}${action.slice(1)} an artifact${action === "undeprecate" ? "" : " (requires confirm: true)"}`, auth: "required", scope: "artifacts:deprecate", bucket: "token-write",151    bodySchema: { type: "object", properties: { reason: { type: "string" }, confirm: { type: "boolean" } } },152    handler: async ({ ctx, params, body }) => { const b = await body(); return artifactDto(await setArtifactState(ctx, params.id!, action, { reason: str(b.reason), confirm: b.confirm === true })); },153  })),154  {155    method: "POST", path: "/releases/:id/revoke", tag: "Publishing", summary: "Revoke a compromised release (requires reason and confirm: true)", auth: "required", scope: "releases:revoke", bucket: "token-write",156    bodySchema: { type: "object", required: ["reason", "confirm"], properties: { reason: { type: "string" }, confirm: { type: "boolean" } } },157    handler: async ({ ctx, params, body }) => { const b = await body(); return revokeRelease(ctx, params.id!, { reason: str(b.reason), confirm: b.confirm === true }); },158  },159  { method: "PATCH", path: "/releases/:id", tag: "Publishing", summary: "Edit release notes (content is immutable)", auth: "required", scope: "releases:update", bucket: "token-write", bodySchema: { type: "object", required: ["releaseNotes"], properties: { releaseNotes: { type: ["string", "null"] } } }, handler: async ({ ctx, params, body }) => updateReleaseNotes(ctx, params.id!, await body()) },160  {161    method: "POST", path: "/validate-manifest", tag: "Publishing", summary: "Validate an agent-registry.json manifest without uploading anything", auth: "required", bucket: "token-write", bodySchema: { type: "object", description: "The manifest object" },162    handler: async ({ body }) => { const r = manifestSchema.safeParse(await body()); return r.success ? { valid: true, manifest: r.data, issues: [] } : { valid: false, issues: r.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) }; },163  },164  {165    method: "GET", path: "/me", tag: "Account", summary: "The authenticated identity, its scopes and namespaces", auth: "required", bucket: "token-write",166    handler: async ({ ctx }) => {167      const p = ctx.principal;168      const u = requireUser(p);169      const namespaces = await listWritableNamespaces(ctx, u);170      return {171        user: { id: u.id, displayName: u.display_name, email: u.email, platformRole: u.platform_role, emailVerified: !!u.email_verified_at },172        credential: p.kind === "token" ? { kind: "token", id: p.token.id, name: p.token.name, prefix: p.token.prefix, scopes: p.token.scopes, namespaces: p.token.namespaces, elevated: p.token.elevated, expiresAt: p.token.expiresAt } : { kind: p.kind },173        namespaces,174      };175    },176  },177  { method: "GET", path: "/me/tokens", tag: "Account", summary: "List your API tokens (metadata only; secrets are never retrievable)", auth: "required", scope: "tokens:read", bucket: "token-write", handler: async ({ ctx }) => ({ items: (await listTokens(ctx)).map(publicToken) }) },178  {179    method: "POST", path: "/me/tokens", tag: "Account", summary: "Create a scoped API token. The secret is returned exactly once.", auth: "required", scope: "tokens:write", bucket: "token-write",180    bodySchema: { type: "object", required: ["name", "scopes"], properties: { name: { type: "string" }, scopes: { type: "array", items: { enum: ALL_SCOPES } }, namespaces: { type: "array", items: { type: "string" } }, expiresInHours: { type: "number" }, ipAllowlist: { type: "array", items: { type: "string" } } } },181    handler: async ({ ctx, body }) => { const { token, row } = await createToken(ctx, (await body()) as never); return Response.json({ token, ...publicToken(row), notice: "Store this token now. It cannot be shown again." }, { status: 201, headers: { "Cache-Control": "no-store" } }); },182  },183  { method: "DELETE", path: "/me/tokens/:id", tag: "Account", summary: "Revoke a token (a token may always revoke itself)", auth: "required", bucket: "token-write", handler: async ({ ctx, params }) => publicToken(await revokeToken(ctx, params.id!)) },184  { method: "POST", path: "/me/tokens/:id/rotate", tag: "Account", summary: "Rotate a token: new secret, same grants; the old token stops working immediately", auth: "required", bucket: "token-write", handler: async ({ ctx, params }) => { const { token, row } = await rotateToken(ctx, params.id!); return Response.json({ token, ...publicToken(row), notice: "Store this token now. It cannot be shown again." }, { status: 201, headers: { "Cache-Control": "no-store" } }); } },185  { method: "GET", path: "/me/audit", tag: "Account", summary: "Your audit trail", auth: "required", scope: "audit:read", bucket: "token-write", query: { before: "Cursor (event id)", limit: "1-200", action: "Action prefix filter" }, handler: async ({ ctx, query }) => listAudit(ctx, { before: num(query.get("before")), limit: num(query.get("limit")), action: query.get("action") ?? undefined }) },186  {187    method: "POST", path: "/reports", tag: "Trust", summary: "Report abuse, malware, leaked secrets or licence problems (no account needed)", auth: "public", bucket: "report",188    bodySchema: { type: "object", required: ["artifact", "category", "details"], properties: { artifact: { type: "string", description: "@namespace/slug" }, version: { type: "string" }, category: { enum: ["malware", "secret-leak", "license", "impersonation", "spam", "broken", "other"] }, details: { type: "string", minLength: 20 }, contact: { type: "string" } } },189    handler: async ({ ctx, body }) => Response.json(await createReport(ctx, (await body()) as never), { status: 201 }),190  },191  { method: "GET", path: "/admin/reports", tag: "Moderation", summary: "List reports (platform moderators)", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx, query }) => ({ items: await listReports(ctx, query.get("state") ?? undefined) }) },192  { method: "POST", path: "/admin/reports/:id/resolve", tag: "Moderation", summary: "Resolve or dismiss a report", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx, params, body }) => resolveReport(ctx, params.id!, (await body()) as never) },193  { method: "GET", path: "/admin/quarantine", tag: "Moderation", summary: "Releases awaiting security review", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx }) => ({ items: await listQuarantined(ctx) }) },194  { method: "POST", path: "/admin/releases/:id/review", tag: "Moderation", summary: "Approve or reject a quarantined release", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx, params, body }) => reviewRelease(ctx, params.id!, (await body()) as never) },195  { method: "POST", path: "/admin/artifacts/:id/moderate", tag: "Moderation", summary: "Take down, restore or (un)verify the source of an artifact", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx, params, body }) => artifactDto(await moderateArtifact(ctx, params.id!, (await body()) as never)) },196  { method: "GET", path: "/admin/audit", tag: "Moderation", summary: "Platform-wide audit trail", auth: "required", scope: "admin:moderate", bucket: "token-write", handler: async ({ ctx, query }) => listAudit(ctx, { all: true, before: num(query.get("before")), limit: num(query.get("limit")), action: query.get("action") ?? undefined }) },197];198199for (const r of routes) {200  const keys: string[] = [];201  // `:name` matches one segment; a trailing `*name` matches the rest of the path (used for file paths).202  r.re = new RegExp("^" + r.path.replace(/:([a-z]+)|\*([a-z]+)$/g, (_, k, rest) => (keys.push(k ?? rest), k ? "([^/]+)" : "(.+)")) + "$");203  r.keys = keys;204}205// `/artifacts/:id` and `/artifacts/:namespace/:slug` never collide (different segment counts).206207function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {208  return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json; charset=utf-8", ...headers } });209}210211function cookieValue(req: Request, name: string): string | null {212  const c = req.headers.get("cookie");213  if (!c) return null;214  for (const part of c.split(";")) {215    const i = part.indexOf("=");216    if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim());217  }218  return null;219}220221async function resolvePrincipal(ctx: Ctx, req: Request, mutating: boolean): Promise<Principal> {222  const authz = req.headers.get("authorization");223  if (authz) {224    const m = /^Bearer\s+(\S+)$/i.exec(authz);225    if (!m) throw unauthorized("Malformed Authorization header. Use `Authorization: Bearer <token>`.", "invalid_token");226    return principalFromBearer(ctx, m[1]!);227  }228  const p = await principalFromSession(ctx, cookieValue(req, SESSION_COOKIE));229  if (p.kind === "session" && mutating) {230    // Cookie-authenticated writes: same-origin + per-session CSRF token.231    const origin = req.headers.get("origin");232    if (!origin || new URL(origin).host !== new URL(env.publicUrl).host) throw forbidden("Cross-origin request refused.", "csrf");233    if (req.headers.get("x-csrf-token") !== csrfTokenFor(p.csrfSecret)) throw forbidden("Missing or invalid CSRF token.", "csrf");234  }235  return p;236}237238const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, If-None-Match", "Access-Control-Max-Age": "86400" };239240/** Entry point for every /api/v1 request. Framework-independent so it can be exercised directly in tests. */241export async function handleApi(req: Request, basePath = "/api/v1"): Promise<Response> {242  const url = new URL(req.url);243  const requestId = randomUUID();244  const path = url.pathname.slice(basePath.length).replace(/\/+$/, "") || "/";245  const method = req.method.toUpperCase();246  const base = { "X-Request-Id": requestId, "X-Content-Type-Options": "nosniff" };247  const ctx = makeCtx({ sql: db(), requestId, ip: clientIp(req.headers), userAgent: req.headers.get("user-agent") ?? "" });248  let route: Route | undefined;249  try {250    if (method === "OPTIONS") return new Response(null, { status: 204, headers: { ...base, ...CORS } });251    if (path === "/openapi.json" && method === "GET") return json(200, openApiDocument(), { ...base, ...CORS, "Cache-Control": "public, max-age=300" });252    const lookup = method === "HEAD" ? "GET" : method;253    let params: Record<string, string> = {};254    let pathMatched = false;255    for (const r of routes) {256      const m = r.re!.exec(path);257      if (!m) continue;258      pathMatched = true;259      if (r.method !== lookup) continue;260      route = r;261      try { params = Object.fromEntries(r.keys!.map((k, i) => [k, decodeURIComponent(m[i + 1]!)])); } catch { throw notFound("Endpoint"); }262      break;263    }264    if (!route) throw pathMatched ? new ApiError(405, "method_not_allowed", `${method} is not supported on this endpoint.`) : notFound("Endpoint");265    const mutating = lookup !== "GET";266    ctx.principal = await resolvePrincipal(ctx, req, mutating);267    const who = ctx.principal.kind === "token" ? `t:${ctx.principal.token.id}` : ctx.principal.kind === "session" ? `u:${ctx.principal.user.id}` : `ip:${ctx.ip}`;268    const rl = route.bucket ? rateLimit(route.bucket, who) : null;269    if (route.auth === "required") {270      const u = requireUser(ctx.principal);271      if (u.must_change_password && ctx.principal.kind === "session") throw forbidden("Change your initial password before using the API.", "password_change_required");272      if (route.scope && ctx.principal.kind === "token" && !ctx.principal.token.scopes.includes(route.scope)) {273        await audit(ctx, { action: "authz.scope_denied", result: "denied", detail: { route: `${route.method} ${route.path}`, required: route.scope } });274        throw forbidden(`This token lacks the "${route.scope}" scope.`, "insufficient_scope", { required: route.scope, granted: ctx.principal.token.scopes });275      }276    }277    let parsed: Record<string, unknown> | undefined;278    const body = async () => {279      if (parsed) return parsed;280      const ct = req.headers.get("content-type") ?? "";281      if (!ct.toLowerCase().startsWith("application/json")) throw new ApiError(415, "unsupported_media_type", "Send a JSON body with Content-Type: application/json.");282      const text = await req.text();283      if (text.length > 512 * 1024) throw new ApiError(413, "payload_too_large", "JSON body is too large.");284      let v: unknown;285      try { v = JSON.parse(text || "{}"); } catch { throw badRequest("Body is not valid JSON."); }286      if (!v || typeof v !== "object" || Array.isArray(v)) throw badRequest("Body must be a JSON object.");287      return (parsed = v as Record<string, unknown>);288    };289290    // Idempotency: replay the stored response for a repeated key; refuse a reused key with a different request.291    const idemKey = route.idempotent ? req.headers.get("idempotency-key") : null;292    const actor = userOf(ctx.principal)?.id ?? "";293    let requestHash = "";294    if (idemKey) {295      if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(idemKey)) throw badRequest("Idempotency-Key must be 8-128 characters (letters, digits, _ . : -).");296      requestHash = createHash("sha256").update(`${route.method} ${path}\0${JSON.stringify(await body())}`).digest("hex");297      const [prev] = await ctx.sql<{ request_hash: string; status: number; body: unknown }[]>`SELECT request_hash, status, body FROM idempotency_keys WHERE actor = ${actor} AND key = ${idemKey}`;298      if (prev) {299        if (prev.request_hash !== requestHash) throw new ApiError(422, "idempotency_key_reused", "This Idempotency-Key was used with a different request.");300        return json(prev.status, prev.body, { ...base, "Idempotent-Replayed": "true", "Cache-Control": "no-store" });301      }302    }303304    const out = await route.handler({ ctx, req, params, query: url.searchParams, body });305    let res: Response;306    if (out instanceof Response) res = out;307    else {308      const text = JSON.stringify(out);309      const anonymous = ctx.principal.kind === "anonymous";310      const headers: Record<string, string> = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": anonymous && !mutating ? "public, max-age=30" : "private, no-store" };311      if (!mutating) {312        const etag = `W/"${createHash("sha256").update(text).digest("base64url").slice(0, 27)}"`;313        headers.ETag = etag;314        if (req.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers: { ...base, ...headers, ...CORS } });315      }316      res = new Response(text, { status: 200, headers });317    }318    if (idemKey && res.status < 500 && (res.headers.get("content-type") ?? "").includes("json")) {319      const stored = await res.clone().json();320      await ctx.sql`INSERT INTO idempotency_keys (actor, key, route, request_hash, status, body) VALUES (${actor}, ${idemKey}, ${`${route.method} ${route.path}`}, ${requestHash}, ${res.status}, ${ctx.sql.json(stored as never)}) ON CONFLICT DO NOTHING`;321    }322    const h = new Headers(res.headers);323    for (const [k, v] of Object.entries(base)) h.set(k, v);324    if (!mutating) for (const [k, v] of Object.entries(CORS)) h.set(k, v);325    if (ctx.principal.kind !== "anonymous" && !h.has("Cache-Control")) h.set("Cache-Control", "private, no-store");326    if (rl) { h.set("RateLimit-Limit", String(rl.limit)); h.set("RateLimit-Remaining", String(Math.max(0, rl.remaining))); h.set("RateLimit-Reset", String(rl.resetSec)); }327    return new Response(method === "HEAD" ? null : res.body, { status: res.status, headers: h });328  } catch (e) {329    if (e instanceof ApiError) {330      return json(e.status, { error: { code: e.code, message: e.message, details: e.details, requestId } }, { ...base, ...(e.headers ?? {}), "Cache-Control": "no-store", ...(method === "GET" ? CORS : {}) });331    }332    const pg = e as { code?: string; message?: string };333    if (pg?.code === "23514" && /immutable|append-only/.test(pg.message ?? "")) {334      return json(409, { error: { code: "release_immutable", message: "Published releases and audit history are immutable.", requestId } }, base);335    }336    if (pg?.code === "23505") return json(409, { error: { code: "conflict", message: "The resource already exists or was created concurrently.", requestId } }, base);337    // Internal details stay in the server log, keyed by request id.338    console.error(JSON.stringify({ level: "error", requestId, route: route ? `${route.method} ${route.path}` : path, error: String((e as Error)?.stack ?? e).slice(0, 4000) }));339    return json(500, { error: { code: "internal_error", message: "Unexpected server error. Quote the request id when reporting it.", requestId } }, { ...base, "Cache-Control": "no-store" });340  }341}342343/* ───────────────────────── OpenAPI 3.1 (generated from the route table) ───────────────────────── */344345export function openApiDocument() {346  const paths: Record<string, Record<string, unknown>> = {};347  for (const r of routes) {348    const p = r.path.replace(/[:*]([a-z]+)/g, "{$1}");349    const parameters: unknown[] = (r.keys ?? []).map((k) => ({ name: k, in: "path", required: true, schema: { type: "string" } }));350    for (const [k, d] of Object.entries(r.query ?? {})) parameters.push({ name: k, in: "query", required: false, description: d, schema: { type: "string" } });351    if (r.idempotent) parameters.push({ name: "Idempotency-Key", in: "header", required: false, description: "Makes the request safely retryable.", schema: { type: "string", minLength: 8, maxLength: 128 } });352    const isUpload = r.method === "PUT";353    (paths[p] ??= {})[r.method.toLowerCase()] = {354      operationId: `${r.method.toLowerCase()}${p.replace(/[{}]/g, "").replace(/\/([a-z])/g, (_, c: string) => c.toUpperCase()).replace(/[^A-Za-z]/g, "")}`,355      summary: r.summary,356      tags: [r.tag],357      parameters,358      ...(r.auth === "required" ? { security: [{ bearerToken: r.scope ? [r.scope] : [] }], "x-required-scope": r.scope ?? null } : { security: [] }),359      ...(r.bodySchema ? { requestBody: { required: true, content: { "application/json": { schema: r.bodySchema } } } } : isUpload ? { requestBody: { required: true, content: { "application/gzip": { schema: { type: "string", format: "binary" } } } } } : {}),360      responses: {361        [r.method === "POST" && /releases$|artifacts$|tokens$|reports$|finalize$|rotate$/.test(r.path) ? "201" : "200"]: { description: "Success" },362        ...(r.auth === "required" ? { "401": { $ref: "#/components/responses/Error" }, "403": { $ref: "#/components/responses/Error" } } : {}),363        "4XX": { $ref: "#/components/responses/Error" },364        "429": { $ref: "#/components/responses/Error" },365      },366    };367  }368  return {369    openapi: "3.1.0",370    info: {371      title: "Skills — Open Agent Capability Registry API", version: API_VERSION,372      description: "Public read access needs no account. Writes use scoped bearer tokens (`Authorization: Bearer skr_…`). Releases are immutable. Scanner results are advisory.",373      license: { name: "MIT", identifier: "MIT" },374    },375    servers: [{ url: `${env.publicUrl}/api/v1` }],376    tags: ["Discovery", "Artifacts", "Publishing", "Account", "Trust", "Moderation", "Health"].map((name) => ({ name })),377    paths,378    components: {379      securitySchemes: { bearerToken: { type: "http", scheme: "bearer", description: `Scoped API token. Scopes: ${Object.entries(SCOPES).map(([k, v]) => `\`${k}\` (${v})`).join("; ")}.` } },380      schemas: {381        Error: { type: "object", required: ["error"], properties: { error: { type: "object", required: ["code", "message", "requestId"], properties: { code: { type: "string" }, message: { type: "string" }, details: {}, requestId: { type: "string" } } } } },382        Finding: { type: "object", required: ["code", "severity", "message", "blocking"], properties: { code: { type: "string" }, severity: { enum: ["info", "low", "medium", "high", "critical"] }, message: { type: "string" }, path: { type: "string" }, line: { type: "integer" }, blocking: { type: "boolean" } } },383      },384      responses: { Error: { description: "Structured error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } },385    },386  };387}388
Files main
Menu

Theme