From cf798fd0efc4cd3a69cf9add5e471d8cbbb31b87 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:58:06 +0700 Subject: [PATCH 01/11] feat(releases): idempotent publish, promote, quarantine review, signed downloads and GitHub OIDC trusted publishing Adds migration 0018 (additive tables plus release_channel), the Idempotency-Key replay store, audited channel promotion, admin approve/reject of quarantined releases, short-lived signed artifact URLs for private releases, and GitHub Actions OIDC exchange for scoped publish tokens with a trusted-publisher config API. All routes stay behind SKILLX_RELEASES_API. Co-Authored-By: Claude Opus 5.5 --- .../app/lib/db/registry-publishing-schema.ts | 71 + apps/web/app/lib/db/schemas.ts | 1 + apps/web/app/lib/releases/github-oidc.ts | 142 + apps/web/app/lib/releases/ingest-release.ts | 4 +- apps/web/app/lib/releases/oidc-exchange.ts | 103 + apps/web/app/lib/releases/release-auth.ts | 23 +- .../app/lib/releases/release-download-url.ts | 99 + .../app/lib/releases/release-idempotency.ts | 137 + apps/web/app/lib/releases/release-promote.ts | 157 + apps/web/app/lib/releases/release-read.ts | 15 +- apps/web/app/lib/releases/release-review.ts | 179 + .../web/app/lib/releases/release-state-cas.ts | 13 +- .../app/lib/releases/release-state-http.ts | 6 +- apps/web/app/lib/releases/release-tokens.ts | 73 + .../app/lib/releases/release-transitions.ts | 8 +- .../app/lib/releases/trusted-publish-token.ts | 72 + .../app/lib/releases/trusted-publishers.ts | 158 + apps/web/app/routes.ts | 8 + .../web/app/routes/api.v1.admin-quarantine.ts | 10 + .../app/routes/api.v1.oidc-github-token.ts | 15 + .../app/routes/api.v1.release-download-url.ts | 15 + .../web/app/routes/api.v1.release-download.ts | 10 + apps/web/app/routes/api.v1.release-promote.ts | 18 + apps/web/app/routes/api.v1.release-publish.ts | 9 +- apps/web/app/routes/api.v1.release-review.ts | 18 + .../app/routes/api.v1.trusted-publishers.ts | 16 + apps/web/drizzle.config.ts | 1 + .../migrations/0018_registry_publishing.sql | 82 + .../migrations/meta/0018_snapshot.json | 3013 +++++++++++++++++ .../web/drizzle/migrations/meta/_journal.json | 7 + .../releases/oidc-trusted-publishing.test.ts | 156 + .../releases/release-download-url.test.ts | 95 + .../releases/release-idempotency.test.ts | 90 + .../tests/releases/release-promote.test.ts | 94 + .../web/tests/releases/release-review.test.ts | 97 + apps/web/workers/app.ts | 7 + .../src/commands/{publish.ts => import.ts} | 0 37 files changed, 5006 insertions(+), 16 deletions(-) create mode 100644 apps/web/app/lib/db/registry-publishing-schema.ts create mode 100644 apps/web/app/lib/releases/github-oidc.ts create mode 100644 apps/web/app/lib/releases/oidc-exchange.ts create mode 100644 apps/web/app/lib/releases/release-download-url.ts create mode 100644 apps/web/app/lib/releases/release-idempotency.ts create mode 100644 apps/web/app/lib/releases/release-promote.ts create mode 100644 apps/web/app/lib/releases/release-review.ts create mode 100644 apps/web/app/lib/releases/release-tokens.ts create mode 100644 apps/web/app/lib/releases/trusted-publish-token.ts create mode 100644 apps/web/app/lib/releases/trusted-publishers.ts create mode 100644 apps/web/app/routes/api.v1.admin-quarantine.ts create mode 100644 apps/web/app/routes/api.v1.oidc-github-token.ts create mode 100644 apps/web/app/routes/api.v1.release-download-url.ts create mode 100644 apps/web/app/routes/api.v1.release-download.ts create mode 100644 apps/web/app/routes/api.v1.release-promote.ts create mode 100644 apps/web/app/routes/api.v1.release-review.ts create mode 100644 apps/web/app/routes/api.v1.trusted-publishers.ts create mode 100644 apps/web/drizzle/migrations/0018_registry_publishing.sql create mode 100644 apps/web/drizzle/migrations/meta/0018_snapshot.json create mode 100644 apps/web/tests/releases/oidc-trusted-publishing.test.ts create mode 100644 apps/web/tests/releases/release-download-url.test.ts create mode 100644 apps/web/tests/releases/release-idempotency.test.ts create mode 100644 apps/web/tests/releases/release-promote.test.ts create mode 100644 apps/web/tests/releases/release-review.test.ts rename packages/cli/src/commands/{publish.ts => import.ts} (100%) diff --git a/apps/web/app/lib/db/registry-publishing-schema.ts b/apps/web/app/lib/db/registry-publishing-schema.ts new file mode 100644 index 0000000..773af2e --- /dev/null +++ b/apps/web/app/lib/db/registry-publishing-schema.ts @@ -0,0 +1,71 @@ +import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"; + +// Release publish idempotency: one row per (publisher, Idempotency-Key). The fingerprint binds the +// key to one exact request (name, version, metadata, body sha256); the stored answer is replayed. +export const releasePublishIdempotency = sqliteTable( + "release_publish_idempotency", + { + publisher_id: text("publisher_id").notNull(), + idempotency_key: text("idempotency_key").notNull(), + request_fingerprint: text("request_fingerprint").notNull(), // sha256:<64 hex> + status: text("status").notNull(), // pending | complete + response_status: integer("response_status"), + response_body: text("response_body"), // JSON body replayed verbatim + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + expires_at: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.publisher_id, table.idempotency_key] })] +); + +// Operator review decisions on quarantined releases. Append-only (triggers in the migration); +// the state change of an approval is audited in release_state_audit in the same batch. +export const releaseReviews = sqliteTable( + "release_reviews", + { + id: text("id").primaryKey(), + release_id: text("release_id").notNull(), + release_digest: text("release_digest"), + decision: text("decision").notNull(), // approved | rejected + actor_kind: text("actor_kind").notNull(), // admin + actor_id: text("actor_id").notNull(), + reason: text("reason"), + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [index("idx_release_reviews_release").on(table.release_id, table.created_at)] +); + +// GitHub Actions OIDC trusted publishers: which repository + workflow (+ ref, environment) may +// exchange an OIDC token for a short-lived releases:publish:@handle token. +export const trustedPublishers = sqliteTable( + "trusted_publishers", + { + id: text("id").primaryKey(), + publisher_id: text("publisher_id").notNull(), + provider: text("provider").notNull(), // github + repository: text("repository").notNull(), // owner/repo, lowercase + repository_id: text("repository_id").notNull(), // numeric GitHub id; survives renames, blocks re-registration + workflow_path: text("workflow_path").notNull(), // .github/workflows/.yml + ref: text("ref"), // e.g. refs/heads/main; null = any ref + environment: text("environment"), // GitHub environment name; null = any + state: text("state").notNull().default("active"), // active | revoked + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + revoked_at: integer("revoked_at", { mode: "timestamp_ms" }), + }, + (table) => [index("idx_trusted_publishers_publisher").on(table.publisher_id)] +); + +// One row per accepted OIDC exchange. The primary key on the token's jti refuses replays. +export const oidcTokenExchanges = sqliteTable( + "oidc_token_exchanges", + { + jti: text("jti").primaryKey(), + trusted_publisher_id: text("trusted_publisher_id").notNull(), + publisher_id: text("publisher_id").notNull(), + repository: text("repository").notNull(), + workflow_ref: text("workflow_ref").notNull(), + run_id: text("run_id"), + created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + token_expires_at: integer("token_expires_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [index("idx_oidc_token_exchanges_trusted").on(table.trusted_publisher_id, table.created_at)] +); diff --git a/apps/web/app/lib/db/schemas.ts b/apps/web/app/lib/db/schemas.ts index 534ad49..0ab9ee1 100644 --- a/apps/web/app/lib/db/schemas.ts +++ b/apps/web/app/lib/db/schemas.ts @@ -8,6 +8,7 @@ export * from "./schema"; export * from "./skill-references-schema"; export * from "./registry-schema"; +export * from "./registry-publishing-schema"; export * from "./entitlements-schema"; export * from "./collections-schema"; export * from "./personal-kits-schema"; diff --git a/apps/web/app/lib/releases/github-oidc.ts b/apps/web/app/lib/releases/github-oidc.ts new file mode 100644 index 0000000..e53f6e3 --- /dev/null +++ b/apps/web/app/lib/releases/github-oidc.ts @@ -0,0 +1,142 @@ +/** + * Verification of a GitHub Actions OIDC token (a JWT signed RS256 by + * `https://token.actions.githubusercontent.com`, keys from its JWKS). + * + * Fails closed with `401 oidc.invalid_token` on anything unexpected: another + * algorithm (only RS256; `none` and HMAC are refused), an unknown key id, a + * bad signature, another issuer or audience, an expired, not-yet-valid or + * over-long-lived token, or a missing claim the trusted publisher binding + * needs. The JWKS is cached per isolate for 10 minutes; an unknown `kid` + * refetches it at most once a minute (key rotation). + */ +import { releaseError } from "./release-http"; + +export const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +export const GITHUB_OIDC_JWKS_URL = `${GITHUB_OIDC_ISSUER}/.well-known/jwks`; +export const DEFAULT_OIDC_AUDIENCE = "skillx"; +const JWKS_TTL_MS = 10 * 60_000; +const JWKS_MIN_REFRESH_MS = 60_000; +const CLOCK_SKEW_MS = 60_000; +/** GitHub tokens live 5 to 10 minutes; anything longer is not one. */ +const MAX_LIFETIME_MS = 60 * 60_000; +const MAX_TOKEN_LENGTH = 16 * 1024; + +export interface GithubOidcClaims { + jti: string; + sub: string; + repository: string; + repository_id: string; + repository_owner_id: string; + workflow_ref: string; + ref: string; + event_name: string; + environment: string | null; + run_id: string | null; +} + +export type JwksFetcher = () => Promise; + +const invalid = (message: string) => releaseError(401, "oidc.invalid_token", message); + +let cache: { keys: Map; fetchedAt: number } | null = null; + +/** Test hook: forget the cached JWKS. */ +export function resetJwksCache(): void { + cache = null; +} + +export const fetchGithubJwks: JwksFetcher = async () => { + const res = await fetch(GITHUB_OIDC_JWKS_URL, { headers: { Accept: "application/json" } }); + if (!res.ok) throw releaseError(503, "oidc.jwks_unavailable", `cannot fetch the GitHub OIDC keys (${res.status})`); + return res.json(); +}; + +async function importJwks(document: unknown): Promise> { + const keys = new Map(); + const list = (document as { keys?: unknown })?.keys; + if (!Array.isArray(list)) return keys; + for (const jwk of list as Array>) { + if (jwk.kty !== "RSA" || typeof jwk.kid !== "string" || typeof jwk.n !== "string" || typeof jwk.e !== "string") continue; + try { + const key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); + keys.set(jwk.kid, key); + } catch { + // A malformed key is skipped; a token naming it fails as unknown. + } + } + return keys; +} + +async function keyFor(kid: string, fetchJwks: JwksFetcher, now: number): Promise { + const stale = cache === null || now - cache.fetchedAt > JWKS_TTL_MS; + const unknownKid = cache !== null && !cache.keys.has(kid) && now - cache.fetchedAt > JWKS_MIN_REFRESH_MS; + if (stale || unknownKid) cache = { keys: await importJwks(await fetchJwks()), fetchedAt: now }; + return cache!.keys.get(kid); +} + +function decodeSegment(segment: string): Record { + try { + const padded = segment.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (segment.length % 4)) % 4); + const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))); + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record; + } catch { + // Reported below. + } + throw invalid("the token is not a JWT"); +} + +function signatureBytes(segment: string): Uint8Array { + try { + const padded = segment.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (segment.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + } catch { + throw invalid("the token signature is malformed"); + } +} + +const str = (value: unknown) => (typeof value === "string" && value !== "" ? value : null); + +export async function verifyGithubOidcToken( + token: string, + options: { audience: string; now: Date; fetchJwks?: JwksFetcher }, +): Promise { + if (token.length > MAX_TOKEN_LENGTH) throw invalid("the token is too long"); + const parts = token.split("."); + if (parts.length !== 3 || parts.some((p) => !/^[A-Za-z0-9_-]+$/.test(p))) throw invalid("the token is not a JWT"); + const [headerPart, payloadPart, signaturePart] = parts as [string, string, string]; + const header = decodeSegment(headerPart); + if (header.alg !== "RS256") throw invalid("the token must be signed with RS256"); + if (typeof header.kid !== "string") throw invalid("the token names no signing key"); + + const now = options.now.getTime(); + const key = await keyFor(header.kid, options.fetchJwks ?? fetchGithubJwks, now); + if (key === undefined) throw invalid("the token is signed by an unknown key"); + const signed = new TextEncoder().encode(`${headerPart}.${payloadPart}`); + const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signatureBytes(signaturePart) as Uint8Array, signed); + if (!ok) throw invalid("the token signature is not valid"); + + const claims = decodeSegment(payloadPart); + if (claims.iss !== GITHUB_OIDC_ISSUER) throw invalid("the token was not issued by GitHub Actions"); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!audiences.includes(options.audience)) throw invalid(`the token audience must be ${options.audience}`); + const exp = typeof claims.exp === "number" ? claims.exp * 1000 : NaN; + const iat = typeof claims.iat === "number" ? claims.iat * 1000 : NaN; + const nbf = typeof claims.nbf === "number" ? claims.nbf * 1000 : iat; + if (!(exp > now)) throw invalid("the token has expired"); + if (!(iat <= now + CLOCK_SKEW_MS) || !(nbf <= now + CLOCK_SKEW_MS)) throw invalid("the token is not valid yet"); + if (exp - iat > MAX_LIFETIME_MS) throw invalid("the token lifetime is too long"); + + const result = { + jti: str(claims.jti), + sub: str(claims.sub), + repository: str(claims.repository), + repository_id: str(claims.repository_id), + repository_owner_id: str(claims.repository_owner_id), + workflow_ref: str(claims.workflow_ref), + ref: str(claims.ref), + event_name: str(claims.event_name), + }; + const missing = Object.entries(result).filter(([, value]) => value === null).map(([name]) => name); + if (missing.length > 0) throw invalid(`the token lacks the claims: ${missing.join(", ")}`); + return { ...(result as Record), environment: str(claims.environment), run_id: str(claims.run_id) }; +} diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts index a3f2b4a..e21757c 100644 --- a/apps/web/app/lib/releases/ingest-release.ts +++ b/apps/web/app/lib/releases/ingest-release.ts @@ -30,6 +30,8 @@ export interface IngestDeps { now?: () => Date; /** Subrequests one publish may spend; defaults to DEFAULT_RELEASE_SUBREQUEST_BUDGET. */ subrequestBudget?: number; + /** `SKILLX_TOKEN_SECRET`; enables OIDC publish tokens (`sxo_...`). */ + tokenSecret?: string | null; } interface ReadTree { @@ -81,7 +83,7 @@ function existingOutcome(identity: string, version: string, existing: ExistingRe async function ingest(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise { const name = parseReleaseName(params.scope, params.slug); - const auth = await authorizeReleasePublisher(deps.db, request, name.handle); + const auth = await authorizeReleasePublisher(deps.db, request, name.handle, { tokenSecret: deps.tokenSecret, now: deps.now?.() }); const signer = await deps.loadSigner(); const metadata = parseReleaseMetadata(request.headers.get(RELEASE_METADATA_HEADER)); const pkg = await findPackage(deps.db, name.identity); diff --git a/apps/web/app/lib/releases/oidc-exchange.ts b/apps/web/app/lib/releases/oidc-exchange.ts new file mode 100644 index 0000000..5e034a9 --- /dev/null +++ b/apps/web/app/lib/releases/oidc-exchange.ts @@ -0,0 +1,103 @@ +/** + * `POST /api/v1/oidc/github/token`: GitHub Actions trusted publishing. + * + * Body `{"token": "", "publisher": "@handle"}`. The JWT is + * verified (`github-oidc.ts`, audience `SKILLX_OIDC_AUDIENCE` or `skillx`) + * and must match an active trusted publisher of that publisher: the same + * `repository_id` and `repository`, a `workflow_ref` of + * `/@...`, and the configured `ref` and + * `environment` when set. Pull request events are refused, so a fork's + * workflow can never publish. Each JWT is exchanged once (its `jti` is the + * primary key of `oidc_token_exchanges`). + * + * Answers `{token, expiresAt, publisher, scope}`: an `sxo_` token valid for + * 15 minutes that only `POST .../releases` of `@handle` accepts (and the + * promote route). It is re-checked against D1 on every use, so revoking the + * trusted publisher or suspending the publisher stops it at once. + * + * Rate limited on `RELEASE_WRITE_LIMITER` per client IP, failing closed. + * Without `SKILLX_TOKEN_SECRET` the route is `503 oidc.unavailable`. + * Mismatches are one `403 oidc.not_trusted` that names no configuration. + */ +import { DEFAULT_OIDC_AUDIENCE, verifyGithubOidcToken } from "./github-oidc"; +import type { GithubOidcClaims, JwksFetcher } from "./github-oidc"; +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; +import { releasesApiEnabled } from "./release-limits"; +import { rateLimitCaller } from "./release-rate-limit"; +import { BARE_PUBLISHER_HANDLE, NO_STORE, enforceWriteLimit, failureHeaders, invalidRequest, methodNotAllowed, readJsonObject } from "./release-state-http"; +import { tokenSecret } from "./release-tokens"; +import { mintOidcPublishToken } from "./trusted-publish-token"; + +const BODY_FIELDS: ReadonlySet = new Set(["token", "publisher"]); +const MAX_BODY_BYTES = 20 * 1024; +const FORBIDDEN_EVENTS = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); + +export interface OidcExchangeDeps { + now?: () => Date; + fetchJwks?: JwksFetcher; +} + +type TrustedRow = { id: string; repository: string; repository_id: string; workflow_path: string; ref: string | null; environment: string | null }; + +const notTrusted = () => releaseError(403, "oidc.not_trusted", "no trusted publisher of this publisher matches the workflow"); + +/** True when the claims satisfy one trusted publisher entry. */ +export function matchesTrustedPublisher(row: TrustedRow, claims: GithubOidcClaims): boolean { + if (claims.repository_id !== row.repository_id) return false; + if (claims.repository.toLowerCase() !== row.repository) return false; + const repoPart = claims.workflow_ref.slice(0, row.repository.length); + const rest = claims.workflow_ref.slice(row.repository.length); + if (repoPart.toLowerCase() !== row.repository || !rest.startsWith(`/${row.workflow_path}@`)) return false; + if (row.ref !== null && (claims.ref !== row.ref || rest !== `/${row.workflow_path}@${row.ref}`)) return false; + if (row.environment !== null && claims.environment !== row.environment) return false; + return true; +} + +export async function serveOidcExchange(request: Request, env: Env, deps: OidcExchangeDeps = {}): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + if (request.method !== "POST") return methodNotAllowed(); + try { + await enforceWriteLimit(env, rateLimitCaller(request, null), true); + const secret = tokenSecret(env); + if (secret === null) throw releaseError(503, "oidc.unavailable", "trusted publishing is not configured"); + const body = await readJsonObject(request, BODY_FIELDS, MAX_BODY_BYTES); + if (typeof body.token !== "string" || body.token === "") throw invalidRequest("token must be the GitHub Actions OIDC token"); + const handle = typeof body.publisher === "string" ? body.publisher.replace(/^@/, "") : ""; + if (!BARE_PUBLISHER_HANDLE.test(handle)) throw invalidRequest("publisher must be @handle"); + + const now = deps.now?.() ?? new Date(); + const claims = await verifyGithubOidcToken(body.token, { + audience: env.SKILLX_OIDC_AUDIENCE || DEFAULT_OIDC_AUDIENCE, + now, + fetchJwks: deps.fetchJwks, + }); + if (FORBIDDEN_EVENTS.has(claims.event_name)) throw releaseError(403, "oidc.event_forbidden", "pull request workflows cannot publish"); + + const publisher = await env.DB.prepare("SELECT id, state FROM publishers WHERE handle = ?").bind(handle).first<{ id: string; state: string }>(); + if (publisher === null) throw notTrusted(); + const { results } = await env.DB.prepare( + "SELECT id, repository, repository_id, workflow_path, ref, environment FROM trusted_publishers WHERE publisher_id = ? AND provider = 'github' AND state = 'active' ORDER BY created_at, id", + ) + .bind(publisher.id) + .all(); + const trusted = results.find((row) => matchesTrustedPublisher(row, claims)); + if (trusted === undefined) throw notTrusted(); + if (publisher.state !== "active") throw releaseError(403, "publisher.inactive", `publisher @${handle} is ${publisher.state}`); + + const { token, expiresAt } = await mintOidcPublishToken(secret, { handle, publisherId: publisher.id, trustedPublisherId: trusted.id, jti: claims.jti }, now); + const recorded = await env.DB.prepare( + `INSERT INTO oidc_token_exchanges (jti, trusted_publisher_id, publisher_id, repository, workflow_ref, run_id, created_at, token_expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(jti) DO NOTHING RETURNING jti`, + ) + .bind(claims.jti, trusted.id, publisher.id, claims.repository, claims.workflow_ref, claims.run_id, now.getTime(), expiresAt.getTime()) + .first<{ jti: string }>(); + if (recorded === null) throw releaseError(409, "oidc.replayed", "this OIDC token was already exchanged"); + + console.info(`oidc publish token issued: @${handle} for ${claims.repository} run ${claims.run_id ?? "?"}`); + return Response.json({ token, expiresAt: expiresAt.toISOString(), publisher: `@${handle}`, scope: `releases:publish:@${handle}` }, { headers: NO_STORE }); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error, failureHeaders(error)); + console.error("oidc exchange failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "oidc exchange failed")); + } +} diff --git a/apps/web/app/lib/releases/release-auth.ts b/apps/web/app/lib/releases/release-auth.ts index 24b8d62..3abf266 100644 --- a/apps/web/app/lib/releases/release-auth.ts +++ b/apps/web/app/lib/releases/release-auth.ts @@ -1,11 +1,14 @@ /** * Authorization of a release upload: a scoped publisher API key * (`releases:publish:@`) whose owner owns the active publisher of the - * package handle. Sessions and legacy unscoped keys are not accepted. + * package handle, or a short-lived OIDC publish token (`sxo_...`, minted by + * the trusted publishing exchange) for exactly that handle. Sessions and + * legacy unscoped keys are not accepted. */ import { hashApiKey, hashScopedApiKey } from "~/lib/auth/api-key-utils"; import { parseStoredScopes, releasePublishScope } from "~/lib/auth/api-key-scopes"; import { releaseError } from "./release-http"; +import { verifyOidcPublishToken } from "./trusted-publish-token"; export interface AuthorizedPublisher { publisherId: string; @@ -27,9 +30,25 @@ function scopeMissing(handle: string) { return releaseError(403, "auth.scope_missing", `this API key does not hold the ${releasePublishScope(handle)} scope`); } -export async function authorizeReleasePublisher(db: D1Database, request: Request, handle: string): Promise { +export interface PublisherAuthOptions { + /** `SKILLX_TOKEN_SECRET`; without it OIDC publish tokens are refused. */ + tokenSecret?: string | null; + now?: Date; +} + +export async function authorizeReleasePublisher( + db: D1Database, + request: Request, + handle: string, + options: PublisherAuthOptions = {}, +): Promise { const header = request.headers.get("Authorization") ?? ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (token.startsWith("sxo_")) { + const authorized = await verifyOidcPublishToken(db, token, handle, options.tokenSecret ?? null, options.now ?? new Date()); + if (authorized === null) throw unauthorized(); + return authorized; + } if (!API_KEY_PATTERN.test(token)) throw unauthorized(); const key = await db.prepare(KEY_QUERY).bind(await hashScopedApiKey(token)).first(); diff --git a/apps/web/app/lib/releases/release-download-url.ts b/apps/web/app/lib/releases/release-download-url.ts new file mode 100644 index 0000000..b486c6c --- /dev/null +++ b/apps/web/app/lib/releases/release-download-url.ts @@ -0,0 +1,99 @@ +/** + * Signed, short-lived artifact download URLs for viewers who may read a + * release (private packages need an entitlement), so a download can be + * handed to a tool that holds no credentials (a package manager, `curl`). + * + * - `POST /api/v1/releases/:digest/artifact/url[?version=&package=]` runs the + * normal read path (viewer, rate limit, access rules of `release-access`) + * and answers `{url, expiresAt, ...}`. The URL is valid for 5 minutes. + * - `GET /api/v1/downloads/:token` streams the artifact. The token is an + * HMAC (`SKILLX_TOKEN_SECRET`, purpose `artifact-download`) over the + * release digest, identity, version, viewer and expiry; it is never an API + * key and grants nothing else. Redemption re-applies the access rules for + * the same viewer, so a revoked entitlement, a quarantine or a suspended + * publisher stops a URL that was already issued. Answers are `no-store`. + * + * The public artifact path (`/api/v1/releases/:digest/artifact`) is + * unchanged. Without the secret, minting is `503 download_url.unavailable` + * and every token is invalid (`404`). + */ +import { RELEASE_DIGEST_PATTERN, RELEASE_VERSION_PATTERN, PACKAGE_NAME_PATTERN } from "@skillx/contracts"; +import { notFound, resolveReleaseByDigest } from "./release-access"; +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; +import { releasesApiEnabled } from "./release-limits"; +import { checkReleaseRateLimit, rateLimitCaller } from "./release-rate-limit"; +import { artifactResponse, resolveDigestTarget, serveReleaseRead } from "./release-read"; +import type { ReadContext } from "./release-read"; +import { signToken, tokenSecret, verifyToken } from "./release-tokens"; + +export const DOWNLOAD_URL_TTL_MS = 5 * 60_000; +const NO_STORE = { "Cache-Control": "no-store" }; + +type Params = Record; + +/** `POST /api/v1/releases/:digest/artifact/url` */ +export function serveDownloadUrlMint(request: Request, env: Env, params: Params, now: () => Date = () => new Date()): Promise { + return serveReleaseRead(request, env, async (ctx: ReadContext) => { + if (request.method !== "POST") { + return Response.json({ error: { code: "method_not_allowed", message: "use POST" } }, { status: 405, headers: { Allow: "POST", ...NO_STORE } }); + } + const release = await resolveDigestTarget(ctx, params); + const secret = tokenSecret(env); + if (secret === null) throw releaseError(503, "download_url.unavailable", "signed download URLs are not configured"); + const exp = now().getTime() + DOWNLOAD_URL_TTL_MS; + const token = await signToken(secret, "artifact-download", { + v: 1, + d: release.releaseDigest, + i: release.identity, + n: release.version, + u: ctx.userId, + exp, + }); + const origin = new URL(request.url).origin; + return Response.json( + { + url: `${origin}/api/v1/downloads/${token}`, + expiresAt: new Date(exp).toISOString(), + identity: release.identity, + version: release.version, + releaseDigest: release.releaseDigest, + artifactDigest: release.artifactDigest, + artifactSize: release.artifactSize, + }, + { headers: NO_STORE }, + ); + }); +} + +function parsePayload(payload: Record | null) { + if (payload === null || payload.v !== 1) return null; + const { d, i, n, u } = payload; + if (typeof d !== "string" || !RELEASE_DIGEST_PATTERN.test(d)) return null; + if (typeof i !== "string" || !PACKAGE_NAME_PATTERN.test(i)) return null; + if (typeof n !== "string" || !RELEASE_VERSION_PATTERN.test(n)) return null; + if (u !== null && typeof u !== "string") return null; + return { digest: d, identity: i, version: n, userId: u as string | null }; +} + +/** `GET /api/v1/downloads/:token` */ +export async function serveDownloadRedeem(request: Request, env: Env, params: Params, now: () => Date = () => new Date()): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + try { + const decision = await checkReleaseRateLimit(env.RELEASE_READ_LIMITER, rateLimitCaller(request, null)); + if (!decision.allowed) { + return errorResponse(releaseError(429, "rate_limited", `too many requests; retry after ${decision.retryAfterSeconds} seconds`), { + "Retry-After": String(decision.retryAfterSeconds), + }); + } + const secret = tokenSecret(env); + const target = secret === null ? null : parsePayload(await verifyToken(secret, "artifact-download", params.token ?? "", now())); + // Forged, expired and unconfigured all answer like an unknown release. + if (target === null) throw notFound(); + const release = await resolveReleaseByDigest(env.DB, target.digest, target.userId, target.version, target.identity); + return await artifactResponse({ db: env.DB, bucket: env.R2, userId: target.userId, request }, release, "no-store"); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error); + console.error("release download failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "release download failed")); + } +} diff --git a/apps/web/app/lib/releases/release-idempotency.ts b/apps/web/app/lib/releases/release-idempotency.ts new file mode 100644 index 0000000..590e8d5 --- /dev/null +++ b/apps/web/app/lib/releases/release-idempotency.ts @@ -0,0 +1,137 @@ +/** + * `Idempotency-Key` on the release publish route. + * + * Without the header a publish runs exactly as before (it is already safe to + * retry: the same version with the same tree answers `created: false`). With + * it, the key is bound to one exact request per publisher, stored in D1 + * (`release_publish_idempotency`) for 24 hours: + * + * - same key, same request (package name, parsed metadata, body sha256): + * the stored answer is replayed verbatim (status and JSON body) with + * `Idempotent-Replayed: true`; nothing is re-read or re-written; + * - same key, different request: `422 idempotency.key_reused`; + * - same key while the first request still runs: `409 idempotency.in_progress`. + * + * Authentication, the name and the metadata are checked before the key is + * claimed, so a rejected credential never consumes a key. Answers below 500 + * are stored (they are deterministic for the same bytes); a 5xx releases the + * claim so the client may retry with the same key. A claim left pending by a + * crashed request is taken over by the same request after 5 minutes. + */ +import { ingestRelease } from "./ingest-release"; +import type { IngestDeps } from "./ingest-release"; +import { authorizeReleasePublisher } from "./release-auth"; +import { ReleaseHttpError, errorResponse, releaseError } from "./release-http"; +import { MAX_RELEASE_UPLOAD_BYTES } from "./release-limits"; +import { RELEASE_METADATA_HEADER, parseReleaseMetadata, parseReleaseName, readUploadBody } from "./release-request"; +import type { ReleaseMetadata } from "./release-request"; + +export const IDEMPOTENCY_HEADER = "Idempotency-Key"; +export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60_000; +export const IDEMPOTENCY_PENDING_TIMEOUT_MS = 5 * 60_000; +/** Printable ASCII without spaces, 1 to 255 characters; an RFC 8941 quoted string is unwrapped. */ +const KEY_PATTERN = /^[\x21-\x7e]{1,255}$/; + +/** The key of the request, null without the header, 400 when malformed. */ +export function parseIdempotencyKey(request: Request): string | null { + const raw = request.headers.get(IDEMPOTENCY_HEADER); + if (raw === null) return null; + const trimmed = raw.trim(); + const key = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed; + if (!KEY_PATTERN.test(key) || key.includes('"')) { + throw releaseError(400, "idempotency.invalid_key", `${IDEMPOTENCY_HEADER} must be 1 to 255 printable ASCII characters without spaces`); + } + return key; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Binds a key to the package, the parsed metadata (fixed field order) and the exact body bytes. */ +export async function requestFingerprint(identity: string, metadata: ReleaseMetadata, body: Uint8Array): Promise { + const { version, channel, kind, sourceProvenance, declaredScripts } = metadata; + const text = JSON.stringify([identity, version, channel, kind, sourceProvenance, declaredScripts, await sha256Hex(body)]); + return `sha256:${await sha256Hex(new TextEncoder().encode(text))}`; +} + +type StoredKey = { request_fingerprint: string; status: string; response_status: number | null; response_body: string | null }; + +/** Claims the key for this request; on conflict reports what holds it. */ +async function claim(db: D1Database, publisherId: string, key: string, fingerprint: string, now: number): Promise { + const result = await db + .prepare( + `INSERT INTO release_publish_idempotency (publisher_id, idempotency_key, request_fingerprint, status, created_at, expires_at) + VALUES (?, ?, ?, 'pending', ?, ?) + ON CONFLICT(publisher_id, idempotency_key) DO UPDATE SET + request_fingerprint = excluded.request_fingerprint, status = 'pending', response_status = NULL, + response_body = NULL, created_at = excluded.created_at, expires_at = excluded.expires_at + WHERE release_publish_idempotency.expires_at <= excluded.created_at + OR (release_publish_idempotency.status = 'pending' AND release_publish_idempotency.created_at <= ? + AND release_publish_idempotency.request_fingerprint = excluded.request_fingerprint)`, + ) + .bind(publisherId, key, fingerprint, now, now + IDEMPOTENCY_TTL_MS, now - IDEMPOTENCY_PENDING_TIMEOUT_MS) + .run(); + if ((result.meta.changes ?? 0) === 1) return null; + const held = await db + .prepare("SELECT request_fingerprint, status, response_status, response_body FROM release_publish_idempotency WHERE publisher_id = ? AND idempotency_key = ?") + .bind(publisherId, key) + .first(); + // Vanished between the two statements (a 5xx released it): treat as in progress, the client retries. + return held ?? { request_fingerprint: fingerprint, status: "pending", response_status: null, response_body: null }; +} + +const NO_STORE = { "Cache-Control": "no-store" }; + +function replay(stored: StoredKey): Response { + return new Response(stored.response_body, { + status: stored.response_status ?? 200, + headers: { "Content-Type": "application/json", "Idempotent-Replayed": "true", ...NO_STORE }, + }); +} + +async function publishOnce(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps, key: string): Promise { + const now = deps.now?.() ?? new Date(); + const name = parseReleaseName(params.scope, params.slug); + const auth = await authorizeReleasePublisher(deps.db, request, name.handle, { tokenSecret: deps.tokenSecret, now }); + const metadata = parseReleaseMetadata(request.headers.get(RELEASE_METADATA_HEADER)); + const body = await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES); + const fingerprint = await requestFingerprint(name.identity, metadata, body); + + const held = await claim(deps.db, auth.publisherId, key, fingerprint, now.getTime()); + if (held !== null) { + if (held.request_fingerprint !== fingerprint) { + throw releaseError(422, "idempotency.key_reused", `${IDEMPOTENCY_HEADER} was already used for a different request`); + } + if (held.status !== "complete") throw releaseError(409, "idempotency.in_progress", "a request with this Idempotency-Key is still in progress; retry later"); + return replay(held); + } + + const forwarded = new Request(request.url, { method: "POST", headers: request.headers, body: body as Uint8Array }); + const response = await ingestRelease(forwarded, params, deps); + const text = await response.text(); + const where = "WHERE publisher_id = ? AND idempotency_key = ? AND request_fingerprint = ? AND status = 'pending'"; + if (response.status >= 500) { + await deps.db.prepare(`DELETE FROM release_publish_idempotency ${where}`).bind(auth.publisherId, key, fingerprint).run(); + } else { + await deps.db + .prepare(`UPDATE release_publish_idempotency SET status = 'complete', response_status = ?, response_body = ? ${where}`) + .bind(response.status, text, auth.publisherId, key, fingerprint) + .run(); + } + return new Response(text, { status: response.status, headers: response.headers }); +} + +/** The publish route: idempotent when the request carries `Idempotency-Key`. */ +export async function publishRelease(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise { + try { + const key = parseIdempotencyKey(request); + if (key === null) return await ingestRelease(request, params, deps); + return await publishOnce(request, params, deps, key); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error); + console.error("release publish failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "release publish failed")); + } +} diff --git a/apps/web/app/lib/releases/release-promote.ts b/apps/web/app/lib/releases/release-promote.ts new file mode 100644 index 0000000..9683dc3 --- /dev/null +++ b/apps/web/app/lib/releases/release-promote.ts @@ -0,0 +1,157 @@ +/** + * Channel promotion: `POST /api/v1/packages/@handle/slug/releases/:version/promote` + * with `{"to", "from"?, "reason"?}` (`application/json`). + * + * Moves a published release between `alpha`, `beta` and `stable` (like an npm + * dist-tag). Only `package_releases.channel` changes: bytes, digests and the + * signed manifest stay as published (the manifest's `channel` records the + * channel at publish time; the listing and `X-SkillX-Release-Channel` carry + * the current one). + * + * Who may move what: + * - a publisher credential of the package handle (scoped + * `releases:publish:@handle` key or OIDC publish token): promotions only + * (alpha -> beta -> stable), so a CI key cannot demote a stable release; + * - the package owner (session or account API key) and the admin + * (`X-Admin-Secret`): any move. + * + * Every attempt on an existing release is audited in `release_state_audit` + * with `subject_kind` `release_channel` and the channels as `from_state` / + * `to_state`, by the same compare-and-set as state changes: `from` (default: + * the current channel when the request is read) must still hold when the + * batch runs, otherwise `409 state_conflict`. The release must be published + * and its publisher active when the batch runs. Requests are counted on + * `RELEASE_WRITE_LIMITER` before the body is read. 404 while + * `SKILLX_RELEASES_API` is off. + */ +import { RELEASE_CHANNELS } from "@skillx/contracts"; +import type { ReleaseChannel } from "@skillx/contracts"; +import { hashScopedApiKey } from "~/lib/auth/api-key-utils"; +import { notFound, parsePackageIdentity, parseReleaseVersion } from "./release-access"; +import { authorizeReleasePublisher } from "./release-auth"; +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; +import { releasesApiEnabled } from "./release-limits"; +import { rateLimitCaller } from "./release-rate-limit"; +import { compareAndSet, recordAttempt } from "./release-state-cas"; +import type { Attempt } from "./release-state-cas"; +import { NO_STORE, enforceWriteLimit, failureHeaders, invalidRequest, methodNotAllowed, parseReason, readJsonObject, resolveLimitedActor } from "./release-state-http"; +import { tokenSecret } from "./release-tokens"; +import type { TransitionActor } from "./release-transitions"; + +const BODY_FIELDS: ReadonlySet = new Set(["from", "to", "reason"]); +const RANK: Readonly> = { alpha: 0, beta: 1, stable: 2 }; +const isChannel = (value: unknown): value is ReleaseChannel => typeof value === "string" && (RELEASE_CHANNELS as readonly string[]).includes(value); + +/** Re-checked inside the batch: the release is published and its publisher active. */ +const PROMOTABLE_GUARD = `EXISTS (SELECT 1 FROM package_releases gr JOIN packages gp ON gp.id = gr.package_id + JOIN publishers gpub ON gpub.id = gp.publisher_id WHERE gr.id = ? AND gr.release_state = 'published' AND gpub.state = 'active')`; + +interface Subject { + id: string; + channel: string; + release_state: string; + release_digest: string | null; + visibility: string; + owner_user_id: string | null; + publisher_state: string; +} + +/** A bearer `sxo_` token, or an `sx_` key stored under the scoped hash. */ +async function isPublisherCredential(db: D1Database, request: Request): Promise { + const header = request.headers.get("Authorization") ?? ""; + const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (token.startsWith("sxo_")) return true; + if (!/^sx_[0-9a-f]{64}$/.test(token)) return false; + const row = await db.prepare("SELECT 1 AS found FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL").bind(await hashScopedApiKey(token)).first(); + return row !== null; +} + +async function resolvePromoteActor(request: Request, env: Env, handle: string, now: Date): Promise { + if (request.headers.get("X-Admin-Secret") === null && (await isPublisherCredential(env.DB, request))) { + const auth = await authorizeReleasePublisher(env.DB, request, handle, { tokenSecret: tokenSecret(env), now }); + await enforceWriteLimit(env, rateLimitCaller(request, `key:${auth.apiKeyId}`), true); + return { kind: "publisher_key", id: auth.apiKeyId }; + } + return resolveLimitedActor(request, env); +} + +export interface PromoteResult { + identity: string; + version: string; + releaseDigest: string | null; + from: ReleaseChannel; + to: ReleaseChannel; + channel: ReleaseChannel; + changedAt: string; +} + +export async function promoteRelease( + db: D1Database, + identity: string, + version: string, + input: { from: ReleaseChannel | null; to: ReleaseChannel; reason: string | null; actor: TransitionActor; now: Date }, +): Promise { + const subject = await db + .prepare( + `SELECT r.id, r.channel, r.release_state, r.release_digest, p.visibility, pub.owner_user_id, pub.state AS publisher_state + FROM package_releases r JOIN packages p ON p.id = r.package_id JOIN publishers pub ON pub.id = p.publisher_id + WHERE p.name = ? AND r.version = ?`, + ) + .bind(identity, version) + .first(); + if (subject === null) throw notFound(); + const from = input.from ?? (isChannel(subject.channel) ? subject.channel : "stable"); + const { to, actor } = input; + const attempt: Attempt = { kind: "release_channel", subjectId: subject.id, input: { from, to, reason: input.reason, actor, now: input.now } }; + const refuse = async (outcome: "denied" | "invalid", error: ReleaseHttpError): Promise => { + await recordAttempt(db, attempt, outcome); + throw error; + }; + + if (actor.kind === "user" && subject.owner_user_id !== actor.id) { + // A private package stays indistinguishable from an unknown one. + await refuse("denied", subject.visibility === "public" ? releaseError(403, "transition.not_owner", `only the owner of ${identity} or an admin may promote it`) : notFound()); + } + if (from === to) await refuse("invalid", releaseError(422, "promote.same_channel", `the release is already in ${to}`)); + if (actor.kind === "publisher_key" && RANK[to] < RANK[from]) { + await refuse("denied", releaseError(403, "promote.demotion_forbidden", "a publisher credential may only promote (alpha -> beta -> stable); demotion needs the owner or an admin")); + } + if (subject.release_state !== "published") { + await refuse("invalid", releaseError(409, "promote.not_published", `only a published release can change channel; this one is ${subject.release_state}`)); + } + if (subject.publisher_state !== "active") { + await refuse("denied", releaseError(403, "publisher_revoked", "the publisher of this package is suspended or revoked")); + } + + const outcome = await compareAndSet(db, attempt, { guard: { sql: PROMOTABLE_GUARD, binds: [subject.id] } }); + if (outcome === "denied") throw releaseError(409, "promote.not_published", "the release is no longer published, or its publisher is not active"); + if (outcome === "stale") throw releaseError(409, "state_conflict", `the release is no longer in ${from}; read its current channel and retry`); + return { identity, version, releaseDigest: subject.release_digest, from, to, channel: to, changedAt: input.now.toISOString() }; +} + +/** Serves one promotion request. */ +export async function servePromote(request: Request, env: Env, params: Record, now: () => Date = () => new Date()): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + if (request.method !== "POST") return methodNotAllowed(); + try { + const identity = parsePackageIdentity(params.scope, params.slug); + const version = parseReleaseVersion(params.version); + const at = now(); + const actor = await resolvePromoteActor(request, env, identity.slice(1, identity.indexOf("/")), at); + const body = await readJsonObject(request, BODY_FIELDS); + if (!isChannel(body.to)) throw invalidRequest(`to must be one of: ${RELEASE_CHANNELS.join(", ")}`); + if (body.from !== undefined && !isChannel(body.from)) throw invalidRequest(`from must be one of: ${RELEASE_CHANNELS.join(", ")}`); + const result = await promoteRelease(env.DB, identity, version, { + from: body.from ?? null, + to: body.to, + reason: parseReason(body.reason), + actor, + now: at, + }); + return Response.json(result, { headers: NO_STORE }); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error, failureHeaders(error)); + console.error("release promote failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "release promote failed")); + } +} diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts index dbedafc..32fc3ba 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -121,6 +121,7 @@ function stateHeaders(release: ReadableRelease): Record { "X-SkillX-Release-Digest": release.releaseDigest, "X-SkillX-Identity": release.identity, "X-SkillX-Version": release.version, + "X-SkillX-Release-Channel": release.channel, }; } @@ -208,7 +209,7 @@ export async function readManifestByVersion(ctx: ReadContext, params: Params): P * that pin one release of the tree; a malformed value is 400, an unknown or * mismatched one 404. */ -async function resolveDigestTarget(ctx: ReadContext, params: Params): Promise { +export async function resolveDigestTarget(ctx: ReadContext, params: Params): Promise { const digest = parseReleaseDigest(params.digest); const query = new URL(ctx.request.url).searchParams; const selector = query.get("version"); @@ -290,7 +291,15 @@ export async function readReleaseFile(ctx: ReadContext, params: Params): Promise /** `GET /api/v1/releases/:digest/artifact[?version=&package=]`: the uploaded tar, streamed from the R2 body. */ export async function readReleaseArtifact(ctx: ReadContext, params: Params): Promise { - const release = await resolveDigestTarget(ctx, params); + return artifactResponse(ctx, await resolveDigestTarget(ctx, params)); +} + +/** + * Streams the artifact of an already authorized release. `cache` overrides + * the immutable public/private cache policy (signed download URLs are + * `no-store`: the URL itself is the short-lived capability). + */ +export async function artifactResponse(ctx: ReadContext, release: ReadableRelease, cache?: string): Promise { if (release.artifactKey === null || release.artifactDigest === null) throw storageError(`release ${release.id} has no artifact`); const object = await ctx.bucket.get(release.artifactKey); if (object === null) throw storageError(`artifact ${release.artifactKey} is missing`); @@ -300,7 +309,7 @@ export async function readReleaseArtifact(ctx: ReadContext, params: Params): Pro } return bytesResponse(ctx.request, object, `"${hexFromKey(release.artifactKey)}"`, { "Content-Type": "application/x-tar", - "Cache-Control": cacheControl(release, IMMUTABLE_TTL_SECONDS, true), + "Cache-Control": cache ?? cacheControl(release, IMMUTABLE_TTL_SECONDS, true), "X-SkillX-Artifact-Digest": release.artifactDigest, ...DOWNLOAD_HEADERS, ...stateHeaders(release), diff --git a/apps/web/app/lib/releases/release-review.ts b/apps/web/app/lib/releases/release-review.ts new file mode 100644 index 0000000..29b7025 --- /dev/null +++ b/apps/web/app/lib/releases/release-review.ts @@ -0,0 +1,179 @@ +/** + * Operator review of quarantined releases (admin only, `X-Admin-Secret`). + * + * - `GET /api/v1/admin/quarantine`: the review queue, quarantined releases + * oldest first with their last review decision. + * - `POST /api/v1/packages/@handle/slug/releases/:version/review` with + * `{"decision": "approve" | "reject", "reason"?}`. + * + * `approve` lifts the quarantine: `quarantined -> yanked -> published` in one + * atomic batch (the v1 status graph has no direct edge, so the signed status + * sequence advances by 2), one `release_state_audit` row per edge and one + * `release_reviews` row, all or nothing. `reject` confirms the quarantine: + * the release stays `quarantined` (its bytes stay blocked in every package) + * and only a `release_reviews` row is written. Both are compare-and-set on + * `quarantined`: a release that is no longer quarantined is `409 + * state_conflict`. Accounts get 403. Requests are counted on + * `RELEASE_WRITE_LIMITER` first. 404 while `SKILLX_RELEASES_API` is off. + */ +import { notFound, parsePackageIdentity, parseReleaseVersion } from "./release-access"; +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; +import { releasesApiEnabled } from "./release-limits"; +import { compareAndSet } from "./release-state-cas"; +import { NO_STORE, failureHeaders, invalidRequest, methodNotAllowed, parseReason, readJsonObject, resolveLimitedActor } from "./release-state-http"; + +export const REVIEW_DECISIONS = ["approve", "reject"] as const; +export type ReviewDecision = (typeof REVIEW_DECISIONS)[number]; +const BODY_FIELDS: ReadonlySet = new Set(["decision", "reason"]); +/** Most queue entries one listing returns. */ +export const MAX_QUEUE_ENTRIES = 200; + +const REVIEW_COLUMNS = "id, release_id, release_digest, decision, actor_kind, actor_id, reason, created_at"; +const reviewId = () => `rrv_${crypto.randomUUID()}`; + +export interface ReviewResult { + identity: string; + version: string; + releaseDigest: string | null; + decision: ReviewDecision; + state: "published" | "quarantined"; + reviewedAt: string; +} + +type Subject = { id: string; release_state: string; release_digest: string | null; signed_manifest_key: string | null }; + +export async function reviewRelease( + db: D1Database, + identity: string, + version: string, + input: { decision: ReviewDecision; reason: string | null; now: Date }, +): Promise { + const subject = await db + .prepare( + `SELECT r.id, r.release_state, r.release_digest, r.signed_manifest_key + FROM package_releases r JOIN packages p ON p.id = r.package_id WHERE p.name = ? AND r.version = ?`, + ) + .bind(identity, version) + .first(); + if (subject === null) throw notFound(); + const conflict = () => releaseError(409, "state_conflict", `the release is ${subject.release_state === "quarantined" ? "no longer" : "not"} quarantined; read its current state`); + if (subject.release_state !== "quarantined") throw conflict(); + const at = input.now.getTime(); + const decided = input.decision === "approve" ? "approved" : "rejected"; + const result = (state: ReviewResult["state"]): ReviewResult => ({ + identity, + version, + releaseDigest: subject.release_digest, + decision: input.decision, + state, + reviewedAt: input.now.toISOString(), + }); + + if (input.decision === "reject") { + const insert = await db + .prepare( + `INSERT INTO release_reviews (${REVIEW_COLUMNS}) + SELECT ?, ?, ?, ?, 'admin', 'admin', ?, ? WHERE EXISTS (SELECT 1 FROM package_releases WHERE id = ? AND release_state = 'quarantined')`, + ) + .bind(reviewId(), subject.id, subject.release_digest, decided, input.reason, at, subject.id) + .run(); + if ((insert.meta.changes ?? 0) !== 1) throw conflict(); + return result("quarantined"); + } + + if (subject.release_digest === null || subject.signed_manifest_key === null) { + throw releaseError(422, "release.incomplete", "a release without a tree digest and signed manifest cannot be published"); + } + const outcome = await compareAndSet( + db, + { kind: "release", subjectId: subject.id, input: { from: "quarantined", to: "published", reason: input.reason, actor: { kind: "admin", id: "admin" }, now: input.now } }, + { + path: ["quarantined", "yanked", "published"], + extraSet: ", published_at = COALESCE(published_at, ?)", + extraBinds: [at], + alsoInsert: [ + { + sql: `INSERT INTO release_reviews (${REVIEW_COLUMNS}) SELECT ?, ?, ?, ?, 'admin', 'admin', ?, ?`, + binds: [reviewId(), subject.id, subject.release_digest, decided, input.reason, at], + }, + ], + }, + ); + if (outcome !== "applied") throw conflict(); + return result("published"); +} + +type QueueRow = { + name: string; + version: string; + channel: string; + release_digest: string | null; + quarantined_at: number | null; + decision: string | null; + review_reason: string | null; + reviewed_at: number | null; +}; + +/** Quarantined releases, oldest quarantine first, each with its latest review (if any). */ +export async function listQuarantine(db: D1Database) { + const { results } = await db + .prepare( + `SELECT p.name, r.version, r.channel, r.release_digest, r.quarantined_at, + rv.decision, rv.reason AS review_reason, rv.created_at AS reviewed_at + FROM package_releases r + JOIN packages p ON p.id = r.package_id + LEFT JOIN release_reviews rv ON rv.id = ( + SELECT id FROM release_reviews WHERE release_id = r.id ORDER BY created_at DESC, rowid DESC LIMIT 1) + WHERE r.release_state = 'quarantined' + ORDER BY r.quarantined_at, r.id LIMIT ?`, + ) + .bind(MAX_QUEUE_ENTRIES) + .all(); + const iso = (ms: number | null) => (ms === null ? null : new Date(Number(ms)).toISOString()); + return results.map((row) => ({ + identity: row.name, + version: row.version, + channel: row.channel, + releaseDigest: row.release_digest, + quarantinedAt: iso(row.quarantined_at), + lastReview: row.decision === null ? null : { decision: row.decision, reason: row.review_reason, reviewedAt: iso(row.reviewed_at) }, + })); +} + +async function requireAdmin(request: Request, env: Env): Promise { + const actor = await resolveLimitedActor(request, env); + if (actor.kind !== "admin") throw releaseError(403, "review.admin_only", "only an admin may review quarantined releases"); +} + +async function serve(env: Env, run: () => Promise): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + try { + return await run(); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error, failureHeaders(error)); + console.error("release review failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "release review failed")); + } +} + +/** `POST .../releases/:version/review` */ +export function serveReview(request: Request, env: Env, params: Record, now: () => Date = () => new Date()): Promise { + return serve(env, async () => { + if (request.method !== "POST") return methodNotAllowed(); + const identity = parsePackageIdentity(params.scope, params.slug); + const version = parseReleaseVersion(params.version); + await requireAdmin(request, env); + const body = await readJsonObject(request, BODY_FIELDS); + if (!(REVIEW_DECISIONS as readonly unknown[]).includes(body.decision)) throw invalidRequest(`decision must be one of: ${REVIEW_DECISIONS.join(", ")}`); + const result = await reviewRelease(env.DB, identity, version, { decision: body.decision as ReviewDecision, reason: parseReason(body.reason), now: now() }); + return Response.json(result, { headers: NO_STORE }); + }); +} + +/** `GET /api/v1/admin/quarantine` */ +export function serveQuarantineQueue(request: Request, env: Env): Promise { + return serve(env, async () => { + await requireAdmin(request, env); + return Response.json({ releases: await listQuarantine(env.DB) }, { headers: NO_STORE }); + }); +} diff --git a/apps/web/app/lib/releases/release-state-cas.ts b/apps/web/app/lib/releases/release-state-cas.ts index 5d3022e..ea41553 100644 --- a/apps/web/app/lib/releases/release-state-cas.ts +++ b/apps/web/app/lib/releases/release-state-cas.ts @@ -7,7 +7,7 @@ */ import type { TransitionInput } from "./release-transitions"; -export type SubjectKind = "release" | "publisher"; +export type SubjectKind = "release" | "publisher" | "release_channel"; export type AttemptOutcome = "denied" | "invalid"; export type CasOutcome = "applied" | "stale" | "denied"; @@ -30,12 +30,19 @@ export interface CasPlan { extraSet?: string; extraBinds?: unknown[]; guard?: CasGuard; + /** + * Extra `INSERT ... SELECT ...` statements of the same batch that must apply + * only with the change: each runs after the update, with `WHERE ` + * appended (so its SQL must end in a `SELECT` without a `WHERE` clause). + */ + alsoInsert?: CasGuard[]; } /** Fixed table and column of each subject kind; never built from input. */ const TARGETS = { release: { table: "package_releases", column: "release_state" }, publisher: { table: "publishers", column: "state" }, + release_channel: { table: "package_releases", column: "channel" }, } as const; export const AUDIT_COLUMNS = "id, subject_kind, subject_id, actor_kind, actor_id, from_state, to_state, outcome, reason, created_at"; @@ -95,7 +102,9 @@ export async function compareAndSet(db: D1Database, attempt: Attempt, plan: CasP AND (SELECT count(*) FROM release_state_audit WHERE id IN (${edgeIds.map(() => "?").join(", ")}) AND outcome = 'applied') = ?`, ) .bind(input.to, ...(plan.extraBinds ?? []), subjectId, input.from, ...edgeIds, edgeIds.length); - await db.batch([refused, ...edges, update]); + const applied = `(SELECT count(*) FROM release_state_audit WHERE id IN (${edgeIds.map(() => "?").join(", ")}) AND outcome = 'applied') = ?`; + const also = (plan.alsoInsert ?? []).map((insert) => db.prepare(`${insert.sql} WHERE ${applied}`).bind(...insert.binds, ...edgeIds, edgeIds.length)); + await db.batch([refused, ...edges, update, ...also]); // Decide from the audit rows, not `meta.changes`: the batch is one transaction, so a // missing refused row means every edge row and the update were applied together. const row = await db.prepare("SELECT outcome FROM release_state_audit WHERE id = ?").bind(requestRowId).first<{ outcome: string }>(); diff --git a/apps/web/app/lib/releases/release-state-http.ts b/apps/web/app/lib/releases/release-state-http.ts index 1cee9ee..a37efda 100644 --- a/apps/web/app/lib/releases/release-state-http.ts +++ b/apps/web/app/lib/releases/release-state-http.ts @@ -58,7 +58,7 @@ async function adminSecretMatches(presented: string, expected: string | undefine return diff === 0; } -export async function resolveActor(request: Request, env: ReleaseStateEnv): Promise { +export async function resolveActor(request: Request, env: ReleaseStateEnv): Promise> { const presented = request.headers.get("X-Admin-Secret"); if (presented !== null) { if (!(await adminSecretMatches(presented, env.ADMIN_SECRET))) throw releaseError(401, "invalid_admin_secret", "X-Admin-Secret is not valid"); @@ -165,7 +165,7 @@ class RateLimitedError extends ReleaseHttpError { } /** Counts one write of `caller`; throws 429 when limited, 503 when a fail-closed limiter is unavailable. */ -async function enforceWriteLimit(env: ReleaseStateEnv, caller: string, failClosed: boolean): Promise { +export async function enforceWriteLimit(env: ReleaseStateEnv, caller: string, failClosed: boolean): Promise { const decision = await checkReleaseRateLimit(env.RELEASE_WRITE_LIMITER, caller, { binding: "RELEASE_WRITE_LIMITER", windowSeconds: RELEASE_WRITE_RATE_WINDOW_SECONDS, @@ -183,7 +183,7 @@ async function enforceWriteLimit(env: ReleaseStateEnv, caller: string, failClose * request leaves no audit row: the append-only audit cannot be flooded faster * than the limit allows. */ -export async function resolveLimitedActor(request: Request, env: ReleaseStateEnv): Promise { +export async function resolveLimitedActor(request: Request, env: ReleaseStateEnv): Promise> { if (request.headers.get("X-Admin-Secret") !== null) await enforceWriteLimit(env, rateLimitCaller(request, null), false); const actor = await resolveActor(request, env); await enforceWriteLimit(env, rateLimitCaller(request, actor.id), actor.kind !== "admin"); diff --git a/apps/web/app/lib/releases/release-tokens.ts b/apps/web/app/lib/releases/release-tokens.ts new file mode 100644 index 0000000..e189ce1 --- /dev/null +++ b/apps/web/app/lib/releases/release-tokens.ts @@ -0,0 +1,73 @@ +/** + * Short-lived HMAC-SHA256 tokens of the release API: OIDC publish tokens and + * signed artifact download URLs. One secret, `SKILLX_TOKEN_SECRET`, serves + * both; every purpose derives its own key (`HMAC(secret, "skillx.token/" + + * purpose)`), so a token minted for one purpose never verifies for another. + * + * Wire form: `base64url(JSON payload) "." base64url(mac)`. Every payload has + * `exp` (Unix milliseconds); an expired token is invalid. Verification uses + * `crypto.subtle.verify`, which compares in constant time. + */ + +/** Secrets shorter than this are treated as unset, so a placeholder value never signs. */ +export const MIN_TOKEN_SECRET_LENGTH = 32; + +export type TokenPurpose = "oidc-publish" | "artifact-download"; + +const encoder = new TextEncoder(); + +function base64url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64url(text: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]*$/.test(text)) return null; + try { + const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + } catch { + return null; + } +} + +/** The configured secret, or null when it is missing or too short. */ +export function tokenSecret(env: { SKILLX_TOKEN_SECRET?: string }): string | null { + const secret = env.SKILLX_TOKEN_SECRET ?? ""; + return secret.length >= MIN_TOKEN_SECRET_LENGTH ? secret : null; +} + +async function purposeKey(secret: string, purpose: TokenPurpose): Promise { + const root = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const derived = await crypto.subtle.sign("HMAC", root, encoder.encode(`skillx.token/${purpose}`)); + return crypto.subtle.importKey("raw", derived, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); +} + +export async function signToken(secret: string, purpose: TokenPurpose, payload: { exp: number } & Record): Promise { + const body = base64url(encoder.encode(JSON.stringify(payload))); + const mac = await crypto.subtle.sign("HMAC", await purposeKey(secret, purpose), encoder.encode(body)); + return `${body}.${base64url(new Uint8Array(mac))}`; +} + +/** The payload of a valid, unexpired token of `purpose`, else null. */ +export async function verifyToken(secret: string, purpose: TokenPurpose, token: string, now: Date): Promise | null> { + if (token.length > 4096) return null; + const [body, mac, extra] = token.split("."); + if (body === undefined || mac === undefined || extra !== undefined) return null; + const signature = fromBase64url(mac); + const json = fromBase64url(body); + if (signature === null || json === null) return null; + const valid = await crypto.subtle.verify("HMAC", await purposeKey(secret, purpose), signature as Uint8Array, encoder.encode(body)); + if (!valid) return null; + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(json)); + } catch { + return null; + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null; + const record = payload as Record; + if (typeof record.exp !== "number" || record.exp <= now.getTime()) return null; + return record; +} diff --git a/apps/web/app/lib/releases/release-transitions.ts b/apps/web/app/lib/releases/release-transitions.ts index b868d36..3405d9b 100644 --- a/apps/web/app/lib/releases/release-transitions.ts +++ b/apps/web/app/lib/releases/release-transitions.ts @@ -49,8 +49,12 @@ const COMPOSITE_RELEASE_PATHS: Readonly> "yanked>quarantined": ["yanked", "published", "quarantined"], }; -/** `admin` is the shared `ADMIN_SECRET` (no individual identity); `user` is a session or account API key. */ -export type TransitionActor = { kind: "admin"; id: "admin" } | { kind: "user"; id: string }; +/** + * `admin` is the shared `ADMIN_SECRET` (no individual identity); `user` is a session or account API key; + * `publisher_key` is a `releases:publish:@handle` credential (scoped key id, or `oidc:`), accepted + * only by channel promotion. + */ +export type TransitionActor = { kind: "admin"; id: "admin" } | { kind: "user"; id: string } | { kind: "publisher_key"; id: string }; export interface TransitionInput { from: S; diff --git a/apps/web/app/lib/releases/trusted-publish-token.ts b/apps/web/app/lib/releases/trusted-publish-token.ts new file mode 100644 index 0000000..6030287 --- /dev/null +++ b/apps/web/app/lib/releases/trusted-publish-token.ts @@ -0,0 +1,72 @@ +/** + * The short-lived publish token of GitHub OIDC trusted publishing: + * `sxo_` (see `release-tokens.ts`), valid for 15 minutes and + * for one publisher handle only. It authorizes exactly what a + * `releases:publish:@handle` key does on the release publish and promote + * routes; every other route rejects it (it is not an `sx_` API key). + * + * The token is stateless, but its use re-reads D1: the publisher must still + * be active and the trusted publisher config it came from still active, so + * revoking the config (or suspending the publisher) stops every token it + * already issued. + */ +import { releaseError } from "./release-http"; +import { signToken, verifyToken } from "./release-tokens"; +import type { AuthorizedPublisher } from "./release-auth"; + +export const OIDC_PUBLISH_TOKEN_PREFIX = "sxo_"; +export const OIDC_PUBLISH_TOKEN_TTL_MS = 15 * 60_000; + +export interface OidcPublishGrant { + handle: string; + publisherId: string; + trustedPublisherId: string; + jti: string; +} + +export async function mintOidcPublishToken(secret: string, grant: OidcPublishGrant, now: Date): Promise<{ token: string; expiresAt: Date }> { + const exp = now.getTime() + OIDC_PUBLISH_TOKEN_TTL_MS; + const signed = await signToken(secret, "oidc-publish", { + v: 1, + h: grant.handle, + p: grant.publisherId, + t: grant.trustedPublisherId, + j: grant.jti, + exp, + }); + return { token: `${OIDC_PUBLISH_TOKEN_PREFIX}${signed}`, expiresAt: new Date(exp) }; +} + +type Row = { owner_user_id: string | null; publisher_state: string; handle: string; trusted_state: string }; + +/** + * The publisher a valid OIDC publish token authorizes for `handle`, or null + * (401) when the token is malformed, forged, expired, or the secret is unset. + * A valid token for another handle is 403 `auth.scope_missing`. + */ +export async function verifyOidcPublishToken( + db: D1Database, + token: string, + handle: string, + secret: string | null, + now: Date, +): Promise { + if (secret === null || !token.startsWith(OIDC_PUBLISH_TOKEN_PREFIX)) return null; + const payload = await verifyToken(secret, "oidc-publish", token.slice(OIDC_PUBLISH_TOKEN_PREFIX.length), now); + if (payload === null || payload.v !== 1) return null; + const { h, p, t, j } = payload; + if (typeof h !== "string" || typeof p !== "string" || typeof t !== "string" || typeof j !== "string") return null; + if (h !== handle) throw releaseError(403, "auth.scope_missing", `this token does not hold the releases:publish:@${handle} scope`); + + const row = await db + .prepare( + `SELECT pub.owner_user_id, pub.state AS publisher_state, pub.handle, tp.state AS trusted_state + FROM publishers pub JOIN trusted_publishers tp ON tp.publisher_id = pub.id + WHERE pub.id = ? AND tp.id = ?`, + ) + .bind(p, t) + .first(); + if (row === null || row.handle !== handle || row.trusted_state !== "active") return null; + if (row.publisher_state !== "active") throw releaseError(403, "publisher.inactive", `publisher @${handle} is ${row.publisher_state}`); + return { publisherId: p, handle, userId: row.owner_user_id ?? "", apiKeyId: `oidc:${j}` }; +} diff --git a/apps/web/app/lib/releases/trusted-publishers.ts b/apps/web/app/lib/releases/trusted-publishers.ts new file mode 100644 index 0000000..f0eb7af --- /dev/null +++ b/apps/web/app/lib/releases/trusted-publishers.ts @@ -0,0 +1,158 @@ +/** + * Admin configuration of trusted publishers: the GitHub Actions workflows + * allowed to exchange their OIDC token for a short-lived publish token of + * one publisher (see `oidc-exchange.ts`). + * + * - `GET /api/v1/publishers/:handle/trusted-publishers` lists them. + * - `POST /api/v1/publishers/:handle/trusted-publishers` adds one: + * `{repository, repositoryId, workflowPath, ref?, environment?, reason?}`. + * `repositoryId` is GitHub's numeric id (`gh api repos/OWNER/REPO --jq .id`); + * it survives a rename and stops a re-created repository of the same name + * from inheriting the trust. An identical active entry is returned as is. + * - `POST /api/v1/publishers/:handle/trusted-publishers/:id/revoke` revokes one. + * + * Admin (`X-Admin-Secret`) only, counted on `RELEASE_WRITE_LIMITER`; every + * change writes a `release_state_audit` row (`subject_kind` + * `trusted_publisher`) in the same batch. Answers are `no-store`. + */ +import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; +import { releasesApiEnabled } from "./release-limits"; +import { AUDIT_COLUMNS } from "./release-state-cas"; +import { BARE_PUBLISHER_HANDLE, NO_STORE, failureHeaders, invalidRequest, parseReason, readJsonObject, resolveLimitedActor } from "./release-state-http"; + +const BODY_FIELDS: ReadonlySet = new Set(["repository", "repositoryId", "workflowPath", "ref", "environment", "reason"]); +const REVOKE_FIELDS: ReadonlySet = new Set(["reason"]); +const REPOSITORY = /^[a-z0-9](?:[a-z0-9-]{0,38})\/[a-z0-9._-]{1,100}$/; +const REPOSITORY_ID = /^[1-9][0-9]{0,19}$/; +const WORKFLOW_PATH = /^\.github\/workflows\/[A-Za-z0-9._-]{1,100}\.ya?ml$/; +const REF = /^refs\/(?:heads|tags)\/[\x21-\x7e]{1,200}$/; +const ENVIRONMENT = /^[\x21-\x7e](?:[\x20-\x7e]{0,253}[\x21-\x7e])?$/; +const MAX_ACTIVE = 20; + +type Params = Record; + +export interface TrustedPublisher { + id: string; + provider: "github"; + repository: string; + repositoryId: string; + workflowPath: string; + ref: string | null; + environment: string | null; + state: "active" | "revoked"; + createdAt: string; + revokedAt: string | null; +} + +type Row = { id: string; provider: "github"; repository: string; repository_id: string; workflow_path: string; ref: string | null; environment: string | null; state: "active" | "revoked"; created_at: number; revoked_at: number | null }; + +const toApi = (r: Row): TrustedPublisher => ({ + id: r.id, + provider: r.provider, + repository: r.repository, + repositoryId: r.repository_id, + workflowPath: r.workflow_path, + ref: r.ref, + environment: r.environment, + state: r.state, + createdAt: new Date(r.created_at).toISOString(), + revokedAt: r.revoked_at === null ? null : new Date(r.revoked_at).toISOString(), +}); + +const optional = (value: unknown, pattern: RegExp, message: string): string | null => { + if (value === undefined || value === null) return null; + if (typeof value !== "string" || !pattern.test(value)) throw invalidRequest(message); + return value; +}; + +async function publisherId(db: D1Database, handle: string | undefined): Promise { + if (handle === undefined || !BARE_PUBLISHER_HANDLE.test(handle)) throw releaseError(404, "publisher.not_found", "unknown publisher"); + const row = await db.prepare("SELECT id FROM publishers WHERE handle = ?").bind(handle).first<{ id: string }>(); + if (row === null) throw releaseError(404, "publisher.not_found", "unknown publisher"); + return row.id; +} + +const SELECT = "SELECT id, provider, repository, repository_id, workflow_path, ref, environment, state, created_at, revoked_at FROM trusted_publishers"; + +export async function listTrustedPublishers(db: D1Database, pubId: string): Promise { + const { results } = await db.prepare(`${SELECT} WHERE publisher_id = ? ORDER BY created_at, id`).bind(pubId).all(); + return results.map(toApi); +} + +export async function addTrustedPublisher(db: D1Database, pubId: string, body: Record, now: Date): Promise<{ trustedPublisher: TrustedPublisher; created: boolean }> { + const repository = typeof body.repository === "string" ? body.repository.toLowerCase() : ""; + if (!REPOSITORY.test(repository)) throw invalidRequest("repository must be owner/repo"); + if (typeof body.repositoryId !== "string" || !REPOSITORY_ID.test(body.repositoryId)) throw invalidRequest("repositoryId must be the numeric GitHub repository id, as a string"); + if (typeof body.workflowPath !== "string" || !WORKFLOW_PATH.test(body.workflowPath)) throw invalidRequest("workflowPath must be .github/workflows/.yml"); + const ref = optional(body.ref, REF, "ref must be refs/heads/ or refs/tags/"); + const environment = optional(body.environment, ENVIRONMENT, "environment must be a GitHub environment name"); + const reason = parseReason(body.reason); + const key = [repository, body.repositoryId, body.workflowPath, ref, environment] as const; + + const same = await db + .prepare(`${SELECT} WHERE publisher_id = ? AND state = 'active' AND repository = ? AND repository_id = ? AND workflow_path = ? AND ref IS ? AND environment IS ?`) + .bind(pubId, ...key) + .first(); + if (same !== null) return { trustedPublisher: toApi(same), created: false }; + + const id = `tp_${crypto.randomUUID()}`; + const at = now.getTime(); + await db.batch([ + db + .prepare( + `INSERT INTO trusted_publishers (id, publisher_id, provider, repository, repository_id, workflow_path, ref, environment, state, created_at) + SELECT ?, ?, 'github', ?, ?, ?, ?, ?, 'active', ? + WHERE (SELECT count(*) FROM trusted_publishers WHERE publisher_id = ? AND state = 'active') < ${MAX_ACTIVE}`, + ) + .bind(id, pubId, ...key, at, pubId), + db + .prepare(`INSERT INTO release_state_audit (${AUDIT_COLUMNS}) SELECT ?, 'trusted_publisher', ?, 'admin', 'admin', 'none', 'active', 'applied', ?, ? WHERE EXISTS (SELECT 1 FROM trusted_publishers WHERE id = ?)`) + .bind(`rsa_${crypto.randomUUID()}`, id, reason, at, id), + ]); + const stored = await db.prepare(`${SELECT} WHERE id = ?`).bind(id).first(); + if (stored === null) throw releaseError(409, "trusted_publisher.limit", `a publisher may have at most ${MAX_ACTIVE} active trusted publishers`); + return { trustedPublisher: toApi(stored), created: true }; +} + +export async function revokeTrustedPublisher(db: D1Database, pubId: string, id: string, reason: string | null, now: Date): Promise { + const at = now.getTime(); + const auditId = `rsa_${crypto.randomUUID()}`; + await db.batch([ + db.prepare("UPDATE trusted_publishers SET state = 'revoked', revoked_at = ? WHERE id = ? AND publisher_id = ? AND state = 'active'").bind(at, id, pubId), + db + .prepare(`INSERT INTO release_state_audit (${AUDIT_COLUMNS}) SELECT ?, 'trusted_publisher', ?, 'admin', 'admin', 'active', 'revoked', 'applied', ?, ? WHERE EXISTS (SELECT 1 FROM trusted_publishers WHERE id = ? AND revoked_at = ?)`) + .bind(auditId, id, reason, at, id, at), + ]); + const stored = await db.prepare(`${SELECT} WHERE id = ? AND publisher_id = ?`).bind(id, pubId).first(); + if (stored === null) throw releaseError(404, "trusted_publisher.not_found", "unknown trusted publisher"); + // This request revoked it exactly when the stored revocation time is ours. + if (stored.revoked_at !== at) throw releaseError(409, "trusted_publisher.already_revoked", "the trusted publisher is already revoked"); + return toApi(stored); +} + +/** Serves the three routes; `params.id` selects revocation. */ +export async function serveTrustedPublishers(request: Request, env: Env, params: Params, now: () => Date = () => new Date()): Promise { + if (!releasesApiEnabled(env)) return notFoundResponse(); + const revoke = params.id !== undefined; + const allowed = revoke ? "POST" : "GET, POST"; + if (!allowed.split(", ").includes(request.method)) { + return Response.json({ error: { code: "method_not_allowed", message: `use ${allowed}` } }, { status: 405, headers: { Allow: allowed, ...NO_STORE } }); + } + try { + const actor = await resolveLimitedActor(request, env); + if (actor.kind !== "admin") throw releaseError(403, "trusted_publisher.admin_only", "only an admin may configure trusted publishers"); + const pubId = await publisherId(env.DB, params.handle); + if (request.method === "GET") return Response.json({ trustedPublishers: await listTrustedPublishers(env.DB, pubId) }, { headers: NO_STORE }); + if (revoke) { + const body = await readJsonObject(request, REVOKE_FIELDS); + const trustedPublisher = await revokeTrustedPublisher(env.DB, pubId, params.id!, parseReason(body.reason), now()); + return Response.json({ trustedPublisher }, { headers: NO_STORE }); + } + const result = await addTrustedPublisher(env.DB, pubId, await readJsonObject(request, BODY_FIELDS), now()); + return Response.json(result, { headers: NO_STORE }); + } catch (error) { + if (error instanceof ReleaseHttpError) return errorResponse(error, failureHeaders(error)); + console.error("trusted publisher change failed:", error instanceof Error ? error.message : String(error)); + return errorResponse(releaseError(500, "release.internal_error", "trusted publisher change failed")); + } +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index b31cad8..9d26410 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -36,8 +36,16 @@ export default [ route("api/v1/packages/:scope/:slug/releases/:version", "routes/api.v1.release-manifest.ts", { id: "release-manifest-by-version" }), route("api/v1/packages/:scope/:slug/releases/:version/state", "routes/api.v1.release-state.ts", { id: "release-state" }), route("api/v1/packages/:scope/:slug/releases/:version/status", "routes/api.v1.release-status.ts"), + route("api/v1/packages/:scope/:slug/releases/:version/promote", "routes/api.v1.release-promote.ts"), + route("api/v1/packages/:scope/:slug/releases/:version/review", "routes/api.v1.release-review.ts"), + route("api/v1/admin/quarantine", "routes/api.v1.admin-quarantine.ts"), route("api/v1/publishers", "routes/api.v1.publishers.ts"), route("api/v1/publishers/:handle/state", "routes/api.v1.release-state.ts", { id: "publisher-state" }), + route("api/v1/publishers/:handle/trusted-publishers", "routes/api.v1.trusted-publishers.ts", { id: "trusted-publishers" }), + route("api/v1/publishers/:handle/trusted-publishers/:id/revoke", "routes/api.v1.trusted-publishers.ts", { id: "trusted-publisher-revoke" }), + route("api/v1/oidc/github/token", "routes/api.v1.oidc-github-token.ts"), + route("api/v1/releases/:digest/artifact/url", "routes/api.v1.release-download-url.ts"), + route("api/v1/downloads/:token", "routes/api.v1.release-download.ts"), route("api/v1/releases/:digest/manifest", "routes/api.v1.release-manifest.ts", { id: "release-manifest-by-digest" }), route("api/v1/releases/:digest/files/*", "routes/api.v1.release-file.ts"), route("api/v1/releases/:digest/artifact", "routes/api.v1.release-artifact.ts"), diff --git a/apps/web/app/routes/api.v1.admin-quarantine.ts b/apps/web/app/routes/api.v1.admin-quarantine.ts new file mode 100644 index 0000000..3f7ac1f --- /dev/null +++ b/apps/web/app/routes/api.v1.admin-quarantine.ts @@ -0,0 +1,10 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { serveQuarantineQueue } from "~/lib/releases/release-review"; + +/** + * `GET /api/v1/admin/quarantine`: the admin review queue of quarantined + * releases (see `release-review.ts`). 404 while `SKILLX_RELEASES_API` is off. + */ +export async function loader({ request, context }: LoaderFunctionArgs) { + return serveQuarantineQueue(request, context.cloudflare.env); +} diff --git a/apps/web/app/routes/api.v1.oidc-github-token.ts b/apps/web/app/routes/api.v1.oidc-github-token.ts new file mode 100644 index 0000000..60ac2ec --- /dev/null +++ b/apps/web/app/routes/api.v1.oidc-github-token.ts @@ -0,0 +1,15 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { serveOidcExchange } from "~/lib/releases/oidc-exchange"; + +/** + * `POST /api/v1/oidc/github/token`: exchange a GitHub Actions OIDC token for a + * short-lived publish token (see `oidc-exchange.ts`). 404 while + * `SKILLX_RELEASES_API` is off; other methods get 405. + */ +export async function loader({ request, context }: LoaderFunctionArgs) { + return serveOidcExchange(request, context.cloudflare.env); +} + +export async function action({ request, context }: ActionFunctionArgs) { + return serveOidcExchange(request, context.cloudflare.env); +} diff --git a/apps/web/app/routes/api.v1.release-download-url.ts b/apps/web/app/routes/api.v1.release-download-url.ts new file mode 100644 index 0000000..eca5ea5 --- /dev/null +++ b/apps/web/app/routes/api.v1.release-download-url.ts @@ -0,0 +1,15 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { serveDownloadUrlMint } from "~/lib/releases/release-download-url"; + +/** + * `POST /api/v1/releases/:digest/artifact/url`: mint a signed, short-lived + * artifact download URL (see `release-download-url.ts`). 404 while + * `SKILLX_RELEASES_API` is off; other methods get 405. + */ +export async function loader({ request, params, context }: LoaderFunctionArgs) { + return serveDownloadUrlMint(request, context.cloudflare.env, params); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return serveDownloadUrlMint(request, context.cloudflare.env, params); +} diff --git a/apps/web/app/routes/api.v1.release-download.ts b/apps/web/app/routes/api.v1.release-download.ts new file mode 100644 index 0000000..aaed259 --- /dev/null +++ b/apps/web/app/routes/api.v1.release-download.ts @@ -0,0 +1,10 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { serveDownloadRedeem } from "~/lib/releases/release-download-url"; + +/** + * `GET /api/v1/downloads/:token`: stream the artifact a signed download URL + * names (see `release-download-url.ts`). 404 while `SKILLX_RELEASES_API` is off. + */ +export async function loader({ request, params, context }: LoaderFunctionArgs) { + return serveDownloadRedeem(request, context.cloudflare.env, params); +} diff --git a/apps/web/app/routes/api.v1.release-promote.ts b/apps/web/app/routes/api.v1.release-promote.ts new file mode 100644 index 0000000..dff937d --- /dev/null +++ b/apps/web/app/routes/api.v1.release-promote.ts @@ -0,0 +1,18 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { notFoundResponse } from "~/lib/releases/release-http"; +import { releasesApiEnabled } from "~/lib/releases/release-limits"; +import { servePromote } from "~/lib/releases/release-promote"; +import { methodNotAllowed } from "~/lib/releases/release-state-http"; + +/** + * `POST /api/v1/packages/@handle/slug/releases/:version/promote`: move a + * published release between channels (see `release-promote.ts`). Answers 404 + * to everything while `SKILLX_RELEASES_API` is off, 405 to other methods. + */ +export async function loader({ context }: LoaderFunctionArgs) { + return releasesApiEnabled(context.cloudflare.env) ? methodNotAllowed() : notFoundResponse(); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return servePromote(request, context.cloudflare.env, params); +} diff --git a/apps/web/app/routes/api.v1.release-publish.ts b/apps/web/app/routes/api.v1.release-publish.ts index eca4cfc..0ac06a5 100644 --- a/apps/web/app/routes/api.v1.release-publish.ts +++ b/apps/web/app/routes/api.v1.release-publish.ts @@ -1,12 +1,14 @@ import type { ActionFunctionArgs } from "react-router"; -import { ingestRelease } from "~/lib/releases/ingest-release"; +import { publishRelease } from "~/lib/releases/release-idempotency"; import { notFoundResponse } from "~/lib/releases/release-http"; import { releaseSubrequestBudget, releasesApiEnabled } from "~/lib/releases/release-limits"; import { loadRegistrySigner } from "~/lib/releases/sign-manifest"; +import { tokenSecret } from "~/lib/releases/release-tokens"; /** * `POST /api/v1/packages/@handle/slug/releases`: upload one immutable release - * as an uncompressed tar. Answers 404 to everything while the Worker var + * as an uncompressed tar, idempotent with an `Idempotency-Key` header + * (see `release-idempotency.ts`). Answers 404 to everything while the Worker var * `SKILLX_RELEASES_API` is off (the default). */ export async function loader({ context }: ActionFunctionArgs) { @@ -21,10 +23,11 @@ export async function action({ request, params, context }: ActionFunctionArgs) { if (request.method !== "POST") { return Response.json({ error: { code: "method_not_allowed", message: "use POST" } }, { status: 405, headers: { Allow: "POST" } }); } - return ingestRelease(request, params, { + return publishRelease(request, params, { db: env.DB, bucket: env.R2, loadSigner: () => loadRegistrySigner(env), subrequestBudget: releaseSubrequestBudget(env), + tokenSecret: tokenSecret(env), }); } diff --git a/apps/web/app/routes/api.v1.release-review.ts b/apps/web/app/routes/api.v1.release-review.ts new file mode 100644 index 0000000..842f387 --- /dev/null +++ b/apps/web/app/routes/api.v1.release-review.ts @@ -0,0 +1,18 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { notFoundResponse } from "~/lib/releases/release-http"; +import { releasesApiEnabled } from "~/lib/releases/release-limits"; +import { serveReview } from "~/lib/releases/release-review"; +import { methodNotAllowed } from "~/lib/releases/release-state-http"; + +/** + * `POST /api/v1/packages/@handle/slug/releases/:version/review`: admin + * approve/reject of a quarantined release (see `release-review.ts`). Answers + * 404 to everything while `SKILLX_RELEASES_API` is off, 405 to other methods. + */ +export async function loader({ context }: LoaderFunctionArgs) { + return releasesApiEnabled(context.cloudflare.env) ? methodNotAllowed() : notFoundResponse(); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return serveReview(request, context.cloudflare.env, params); +} diff --git a/apps/web/app/routes/api.v1.trusted-publishers.ts b/apps/web/app/routes/api.v1.trusted-publishers.ts new file mode 100644 index 0000000..a9576ba --- /dev/null +++ b/apps/web/app/routes/api.v1.trusted-publishers.ts @@ -0,0 +1,16 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { serveTrustedPublishers } from "~/lib/releases/trusted-publishers"; + +/** + * `GET|POST /api/v1/publishers/:handle/trusted-publishers` and + * `POST .../trusted-publishers/:id/revoke`: admin configuration of GitHub + * Actions trusted publishing (see `trusted-publishers.ts`). 404 while + * `SKILLX_RELEASES_API` is off. + */ +export async function loader({ request, params, context }: LoaderFunctionArgs) { + return serveTrustedPublishers(request, context.cloudflare.env, params); +} + +export async function action({ request, params, context }: ActionFunctionArgs) { + return serveTrustedPublishers(request, context.cloudflare.env, params); +} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 83364de..d4f0a11 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ schema: [ "./app/lib/db/schema.ts", "./app/lib/db/registry-schema.ts", + "./app/lib/db/registry-publishing-schema.ts", "./app/lib/db/entitlements-schema.ts", "./app/lib/db/collections-schema.ts", "./app/lib/db/personal-kits-schema.ts", diff --git a/apps/web/drizzle/migrations/0018_registry_publishing.sql b/apps/web/drizzle/migrations/0018_registry_publishing.sql new file mode 100644 index 0000000..108ebfe --- /dev/null +++ b/apps/web/drizzle/migrations/0018_registry_publishing.sql @@ -0,0 +1,82 @@ +-- Release publishing: idempotency keys, operator review, GitHub OIDC trusted publishing (issues #34, #54) +-- +-- Strictly additive: four new tables, their indexes and two triggers. No existing table or row +-- changes. Numbered 0018 because the drizzle journal already holds 18 entries (0010_greedy_famine +-- was journaled last, with snapshot 0017). +-- +-- - release_publish_idempotency: one row per (publisher, Idempotency-Key) of a release upload. +-- - release_reviews: operator approve/reject decisions on quarantined releases; append-only. +-- - trusted_publishers: repository + workflow (+ ref, environment) allowed to exchange a GitHub +-- Actions OIDC token for a short-lived releases:publish:@handle token. +-- - oidc_token_exchanges: one row per accepted exchange; its jti primary key refuses replays. +-- +-- Rollback (take a D1 time-travel bookmark first; review and exchange history is lost). The last +-- statement removes the wrangler bookkeeping row, so a later `migrations apply` re-applies 0018: +-- DROP TRIGGER IF EXISTS release_reviews_no_delete; +-- DROP TRIGGER IF EXISTS release_reviews_no_update; +-- DROP INDEX IF EXISTS idx_trusted_publishers_publisher; +-- DROP TABLE IF EXISTS trusted_publishers; +-- DROP INDEX IF EXISTS idx_release_reviews_release; +-- DROP TABLE IF EXISTS release_reviews; +-- DROP TABLE IF EXISTS release_publish_idempotency; +-- DROP INDEX IF EXISTS idx_oidc_token_exchanges_trusted; +-- DROP TABLE IF EXISTS oidc_token_exchanges; +-- DELETE FROM d1_migrations WHERE name = '0018_registry_publishing.sql'; +CREATE TABLE `oidc_token_exchanges` ( + `jti` text PRIMARY KEY NOT NULL, + `trusted_publisher_id` text NOT NULL, + `publisher_id` text NOT NULL, + `repository` text NOT NULL, + `workflow_ref` text NOT NULL, + `run_id` text, + `created_at` integer NOT NULL, + `token_expires_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_oidc_token_exchanges_trusted` ON `oidc_token_exchanges` (`trusted_publisher_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `release_publish_idempotency` ( + `publisher_id` text NOT NULL, + `idempotency_key` text NOT NULL, + `request_fingerprint` text NOT NULL, + `status` text NOT NULL, + `response_status` integer, + `response_body` text, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL, + PRIMARY KEY(`publisher_id`, `idempotency_key`) +); +--> statement-breakpoint +CREATE TABLE `release_reviews` ( + `id` text PRIMARY KEY NOT NULL, + `release_id` text NOT NULL, + `release_digest` text, + `decision` text NOT NULL, + `actor_kind` text NOT NULL, + `actor_id` text NOT NULL, + `reason` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_release_reviews_release` ON `release_reviews` (`release_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `trusted_publishers` ( + `id` text PRIMARY KEY NOT NULL, + `publisher_id` text NOT NULL, + `provider` text NOT NULL, + `repository` text NOT NULL, + `repository_id` text NOT NULL, + `workflow_path` text NOT NULL, + `ref` text, + `environment` text, + `state` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `revoked_at` integer +); +--> statement-breakpoint +CREATE INDEX `idx_trusted_publishers_publisher` ON `trusted_publishers` (`publisher_id`); +--> statement-breakpoint +CREATE TRIGGER IF NOT EXISTS release_reviews_no_update BEFORE UPDATE ON release_reviews BEGIN + SELECT RAISE(ABORT, 'release_reviews is append-only'); +END;--> statement-breakpoint +CREATE TRIGGER IF NOT EXISTS release_reviews_no_delete BEFORE DELETE ON release_reviews BEGIN + SELECT RAISE(ABORT, 'release_reviews is append-only'); +END; \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/0018_snapshot.json b/apps/web/drizzle/migrations/meta/0018_snapshot.json new file mode 100644 index 0000000..142c581 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0018_snapshot.json @@ -0,0 +1,3013 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d0cd266-cecf-468c-8fc4-b07b44ced5ce", + "prevId": "187b8911-9ea8-40e1-8816-6ad0b8abb8fe", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "idx_api_keys_user": { + "name": "idx_api_keys_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_api_keys_hash": { + "name": "idx_api_keys_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_favorites_pk": { + "name": "idx_favorites_pk", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_favorites_skill": { + "name": "idx_favorites_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_skill_id_skills_id_fk": { + "name": "favorites_skill_id_skills_id_fk", + "tableFrom": "favorites", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "installs": { + "name": "installs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_installs_skill": { + "name": "idx_installs_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_installs_user": { + "name": "idx_installs_user", + "columns": [ + "skill_id", + "user_id" + ], + "isUnique": true, + "where": "user_id IS NOT NULL" + }, + "idx_installs_device": { + "name": "idx_installs_device", + "columns": [ + "skill_id", + "device_id" + ], + "isUnique": true, + "where": "device_id IS NOT NULL" + } + }, + "foreignKeys": { + "installs_skill_id_skills_id_fk": { + "name": "installs_skill_id_skills_id_fk", + "tableFrom": "installs", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ratings_user_skill": { + "name": "idx_ratings_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_ratings_skill": { + "name": "idx_ratings_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_skill_id_skills_id_fk": { + "name": "ratings_skill_id_skills_id_fk", + "tableFrom": "ratings", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_agent": { + "name": "is_agent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_reviews_skill": { + "name": "idx_reviews_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_reviews_user": { + "name": "idx_reviews_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "reviews_skill_id_skills_id_fk": { + "name": "reviews_skill_id_skills_id_fk", + "tableFrom": "reviews", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_aliases": { + "name": "skill_aliases", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_aliases_skill": { + "name": "idx_skill_aliases_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_aliases_skill_id_skills_id_fk": { + "name": "skill_aliases_skill_id_skills_id_fk", + "tableFrom": "skill_aliases", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skills": { + "name": "skills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_repo": { + "name": "source_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'1.0.0'" + }, + "is_paid": { + "name": "is_paid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avg_rating": { + "name": "avg_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "github_stars": { + "name": "github_stars", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "composite_score": { + "name": "composite_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "bayesian_rating": { + "name": "bayesian_rating", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "trending_score": { + "name": "trending_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "upvote_count": { + "name": "upvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downvote_count": { + "name": "downvote_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "net_votes": { + "name": "net_votes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "scripts": { + "name": "scripts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fts_content": { + "name": "fts_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "risk_label": { + "name": "risk_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_skills_category": { + "name": "idx_skills_category", + "columns": [ + "category" + ], + "isUnique": false + }, + "idx_skills_author": { + "name": "idx_skills_author", + "columns": [ + "author" + ], + "isUnique": false + }, + "idx_skills_avg_rating": { + "name": "idx_skills_avg_rating", + "columns": [ + "avg_rating" + ], + "isUnique": false + }, + "idx_skills_composite_score": { + "name": "idx_skills_composite_score", + "columns": [ + "composite_score" + ], + "isUnique": false + }, + "idx_skills_trending_score": { + "name": "idx_skills_trending_score", + "columns": [ + "trending_score" + ], + "isUnique": false + }, + "idx_skills_net_votes": { + "name": "idx_skills_net_votes", + "columns": [ + "net_votes" + ], + "isUnique": false + }, + "idx_skills_source_identity": { + "name": "idx_skills_source_identity", + "columns": [ + "source_repo", + "source_path" + ], + "isUnique": true, + "where": "source_repo IS NOT NULL" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_stats": { + "name": "usage_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_skill": { + "name": "idx_usage_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "idx_usage_created": { + "name": "idx_usage_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "usage_stats_skill_id_skills_id_fk": { + "name": "usage_stats_skill_id_skills_id_fk", + "tableFrom": "usage_stats", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "votes": { + "name": "votes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_votes_user_skill": { + "name": "idx_votes_user_skill", + "columns": [ + "user_id", + "skill_id" + ], + "isUnique": true + }, + "idx_votes_skill": { + "name": "idx_votes_skill", + "columns": [ + "skill_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "votes_skill_id_skills_id_fk": { + "name": "votes_skill_id_skills_id_fk", + "tableFrom": "votes", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_releases": { + "name": "package_releases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "release_state": { + "name": "release_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "manifest_schema_version": { + "name": "manifest_schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_size": { + "name": "artifact_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tree_size": { + "name": "tree_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_manifest_key": { + "name": "signed_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_provenance": { + "name": "source_provenance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compatibility_json": { + "name": "compatibility_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "yanked_at": { + "name": "yanked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_sequence": { + "name": "status_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_issued_at": { + "name": "status_issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exec_summary_json": { + "name": "exec_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_package_releases_version": { + "name": "idx_package_releases_version", + "columns": [ + "package_id", + "version" + ], + "isUnique": true + }, + "idx_package_releases_state": { + "name": "idx_package_releases_state", + "columns": [ + "release_state" + ], + "isUnique": false + }, + "idx_package_releases_digest": { + "name": "idx_package_releases_digest", + "columns": [ + "release_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "package_releases_package_id_packages_id_fk": { + "name": "package_releases_package_id_packages_id_fk", + "tableFrom": "package_releases", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "packages": { + "name": "packages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_packages_name": { + "name": "idx_packages_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_packages_publisher": { + "name": "idx_packages_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "packages_publisher_id_publishers_id_fk": { + "name": "packages_publisher_id_publishers_id_fk", + "tableFrom": "packages", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "publishers": { + "name": "publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "publishers_handle_unique": { + "name": "publishers_handle_unique", + "columns": [ + "handle" + ], + "isUnique": true + }, + "idx_publishers_owner": { + "name": "idx_publishers_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_state_audit": { + "name": "release_state_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_state_audit_subject": { + "name": "idx_release_state_audit_subject", + "columns": [ + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_verification_evidence": { + "name": "release_verification_evidence", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness_version": { + "name": "harness_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_id": { + "name": "probe_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verifier": { + "name": "verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_evidence_release": { + "name": "idx_release_evidence_release", + "columns": [ + "release_id" + ], + "isUnique": false + }, + "idx_release_evidence_probe": { + "name": "idx_release_evidence_probe", + "columns": [ + "release_id", + "harness", + "probe_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "release_verification_evidence_release_id_package_releases_id_fk": { + "name": "release_verification_evidence_release_id_package_releases_id_fk", + "tableFrom": "release_verification_evidence", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_token_exchanges": { + "name": "oidc_token_exchanges", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trusted_publisher_id": { + "name": "trusted_publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_ref": { + "name": "workflow_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_oidc_token_exchanges_trusted": { + "name": "idx_oidc_token_exchanges_trusted", + "columns": [ + "trusted_publisher_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_publish_idempotency": { + "name": "release_publish_idempotency", + "columns": { + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "release_publish_idempotency_publisher_id_idempotency_key_pk": { + "columns": [ + "publisher_id", + "idempotency_key" + ], + "name": "release_publish_idempotency_publisher_id_idempotency_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "release_reviews": { + "name": "release_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_digest": { + "name": "release_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_release_reviews_release": { + "name": "idx_release_reviews_release", + "columns": [ + "release_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_publishers": { + "name": "trusted_publishers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_trusted_publishers_publisher": { + "name": "idx_trusted_publishers_publisher", + "columns": [ + "publisher_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_entitlements_subject": { + "name": "idx_entitlements_subject", + "columns": [ + "subject_id" + ], + "isUnique": false + }, + "idx_entitlements_package": { + "name": "idx_entitlements_package", + "columns": [ + "package_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entitlements_package_id_packages_id_fk": { + "name": "entitlements_package_id_packages_id_fk", + "tableFrom": "entitlements", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlements_release_id_package_releases_id_fk": { + "name": "entitlements_release_id_package_releases_id_fk", + "tableFrom": "entitlements", + "tableTo": "package_releases", + "columnsFrom": [ + "release_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_revisions": { + "name": "collection_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "members_json": { + "name": "members_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collection_revisions_number": { + "name": "idx_collection_revisions_number", + "columns": [ + "collection_id", + "revision_number" + ], + "isUnique": true + }, + "idx_collection_revisions_collection": { + "name": "idx_collection_revisions_collection", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_revisions_collection_id_collections_id_fk": { + "name": "collection_revisions_collection_id_collections_id_fk", + "tableFrom": "collection_revisions", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_collections_owner_slug": { + "name": "idx_collections_owner_slug", + "columns": [ + "owner_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_audit": { + "name": "personal_kit_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_audit_kit": { + "name": "idx_personal_kit_audit_kit", + "columns": [ + "kit_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_conflicts": { + "name": "personal_kit_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "report_json": { + "name": "report_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "resolution_json": { + "name": "resolution_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_revision_id": { + "name": "resolved_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reeval_required": { + "name": "reeval_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_conflicts_kit": { + "name": "idx_personal_kit_conflicts_kit", + "columns": [ + "kit_id", + "status" + ], + "isUnique": false + }, + "idx_personal_kit_conflicts_idempotency": { + "name": "idx_personal_kit_conflicts_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "personal_kit_conflicts_kit_id_personal_kits_id_fk": { + "name": "personal_kit_conflicts_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_conflicts", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_proposals": { + "name": "personal_kit_proposals", + "columns": { + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_permissions_json": { + "name": "declared_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_permissions_json": { + "name": "required_permissions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scan_json": { + "name": "scan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_proposals_kit": { + "name": "idx_personal_kit_proposals_kit", + "columns": [ + "kit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk": { + "name": "personal_kit_proposals_revision_id_personal_kit_revisions_id_fk", + "tableFrom": "personal_kit_proposals", + "tableTo": "personal_kit_revisions", + "columnsFrom": [ + "revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kit_revisions": { + "name": "personal_kit_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kit_id": { + "name": "kit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_revision_id": { + "name": "parent_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision_digest": { + "name": "revision_digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_json": { + "name": "document_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approval_json": { + "name": "approval_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activated_at": { + "name": "activated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kit_revisions_number": { + "name": "idx_personal_kit_revisions_number", + "columns": [ + "kit_id", + "revision_number" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_idempotency": { + "name": "idx_personal_kit_revisions_idempotency", + "columns": [ + "kit_id", + "idempotency_key" + ], + "isUnique": true + }, + "idx_personal_kit_revisions_digest": { + "name": "idx_personal_kit_revisions_digest", + "columns": [ + "kit_id", + "revision_digest" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kit_revisions_kit_id_personal_kits_id_fk": { + "name": "personal_kit_revisions_kit_id_personal_kits_id_fk", + "tableFrom": "personal_kit_revisions", + "tableTo": "personal_kits", + "columnsFrom": [ + "kit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "personal_kits": { + "name": "personal_kits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_id": { + "name": "local_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "effective_revision_id": { + "name": "effective_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_personal_kits_name": { + "name": "idx_personal_kits_name", + "columns": [ + "name" + ], + "isUnique": true + }, + "idx_personal_kits_owner": { + "name": "idx_personal_kits_owner", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "personal_kits_publisher_id_publishers_id_fk": { + "name": "personal_kits_publisher_id_publishers_id_fk", + "tableFrom": "personal_kits", + "tableTo": "publishers", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/migrations/meta/_journal.json b/apps/web/drizzle/migrations/meta/_journal.json index 4a9285f..883ae2e 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1792066514896, "tag": "0010_greedy_famine", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1792412883490, + "tag": "0018_registry_publishing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/tests/releases/oidc-trusted-publishing.test.ts b/apps/web/tests/releases/oidc-trusted-publishing.test.ts new file mode 100644 index 0000000..9c74d52 --- /dev/null +++ b/apps/web/tests/releases/oidc-trusted-publishing.test.ts @@ -0,0 +1,156 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { action as trustedAction, loader as trustedLoader } from "~/routes/api.v1.trusted-publishers"; +import { GITHUB_OIDC_ISSUER, resetJwksCache } from "~/lib/releases/github-oidc"; +import { serveOidcExchange } from "~/lib/releases/oidc-exchange"; +import { tarOf } from "./release-harness"; +import { ADMIN, audit, envOf, skillFiles, useStateHarness } from "./release-state-helpers"; +import type { Harness } from "./release-state-helpers"; + +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +const SECRET = "test-token-secret-0123456789abcdef"; +const NOW = new Date("2026-10-13T12:00:00Z"); +const b64url = (bytes: Uint8Array | string) => Buffer.from(bytes).toString("base64url"); +let keys: CryptoKeyPair; +let jwks: unknown; +let h: Harness; + +beforeAll(async () => { + keys = (await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + jwks = { keys: [{ ...(await crypto.subtle.exportKey("jwk", keys.publicKey)), kid: "k1", use: "sig" }] }; +}); + +beforeEach(async () => { + h = await useStateHarness(); + resetJwksCache(); +}); + +const baseClaims = () => ({ + iss: GITHUB_OIDC_ISSUER, + aud: "skillx", + iat: NOW.getTime() / 1000 - 10, + nbf: NOW.getTime() / 1000 - 10, + exp: NOW.getTime() / 1000 + 290, + jti: crypto.randomUUID(), + sub: "repo:nextlevelbuilder/skillx:ref:refs/heads/main", + repository: "nextlevelbuilder/skillx", + repository_id: "42", + repository_owner_id: "7", + workflow_ref: "nextlevelbuilder/skillx/.github/workflows/publish-agentkit-foundation.yml@refs/heads/main", + ref: "refs/heads/main", + event_name: "workflow_dispatch", + run_id: "99", +}); + +async function jwt(claims: Record, header: Record = { alg: "RS256", kid: "k1", typ: "JWT" }, key = keys.privateKey) { + const signing = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`; + const sig = new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signing))); + return `${signing}.${b64url(sig)}`; +} + +async function configure(body: Record, auth: Record = { "X-Admin-Secret": ADMIN }, method = "POST", id?: string) { + const path = `/api/v1/publishers/bestagentkits/trusted-publishers${id ? `/${id}/revoke` : ""}`; + const request = new Request(`https://skillx.test${path}`, { method, headers: { "Content-Type": "application/json", ...auth }, body: method === "GET" ? undefined : JSON.stringify(body) }); + const args = { request, params: { handle: "bestagentkits", id }, context: { cloudflare: { env: envOf() } } } as never; + const res = method === "GET" ? await trustedLoader(args) : await trustedAction(args); + return { status: res.status, body: (await res.json()) as Record }; +} + +const TRUST = { repository: "NextLevelBuilder/skillx", repositoryId: "42", workflowPath: ".github/workflows/publish-agentkit-foundation.yml", ref: "refs/heads/main" }; + +async function exchange(token: string, publisher = "@bestagentkits", env: Record = { SKILLX_TOKEN_SECRET: SECRET }) { + const request = new Request("https://skillx.test/api/v1/oidc/github/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, publisher }) }); + const res = await serveOidcExchange(request, envOf(env) as never, { now: () => NOW, fetchJwks: async () => jwks }); + return { status: res.status, body: (await res.json()) as Record }; +} + +describe("trusted publisher configuration", () => { + it("is admin only, idempotent, audited, and revocable", async () => { + expect((await configure(TRUST, { Authorization: `Bearer ${await h.createKey("user-a", null)}` })).status).toBe(403); + const added = await configure(TRUST); + expect(added.status).toBe(200); + expect(added.body).toMatchObject({ created: true, trustedPublisher: { repository: "nextlevelbuilder/skillx", repositoryId: "42", state: "active" } }); + expect((await configure(TRUST)).body.created).toBe(false); + expect((await configure({}, undefined, "GET")).body.trustedPublishers).toHaveLength(1); + const id = added.body.trustedPublisher.id as string; + expect((await configure({ reason: "rotated" }, undefined, "POST", id)).body.trustedPublisher.state).toBe("revoked"); + expect((await configure({}, undefined, "POST", id)).status).toBe(409); + expect(audit().filter((r) => r.subject_kind === "trusted_publisher").map((r) => [r.from_state, r.to_state])).toEqual([ + ["none", "active"], + ["active", "revoked"], + ]); + }); + + it("validates every field", async () => { + for (const bad of [{ ...TRUST, repositoryId: 42 }, { ...TRUST, workflowPath: "publish.yml" }, { ...TRUST, ref: "main" }, { ...TRUST, repository: "bad" }, { ...TRUST, extra: 1 }]) { + expect((await configure(bad)).status).toBe(400); + } + }); +}); + +describe("OIDC exchange", () => { + it("exchanges a matching token once for a publish token that publishes the release", async () => { + await configure(TRUST); + const token = await jwt(baseClaims()); + const res = await exchange(token); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ publisher: "@bestagentkits", scope: "releases:publish:@bestagentkits" }); + expect(res.body.token).toMatch(/^sxo_/); + expect(new Date(res.body.expiresAt).getTime() - NOW.getTime()).toBe(15 * 60_000); + expect((await exchange(token)).body.error.code).toBe("oidc.replayed"); + + const deps = { ...h.deps, tokenSecret: SECRET }; + const publish = (await import("~/lib/releases/ingest-release")).ingestRelease; + const req = h.request("@bestagentkits/foundation", tarOf(skillFiles()) as BodyInit, { key: res.body.token }); + const published = await publish(req, { scope: "@bestagentkits", slug: "foundation" }, deps); + expect(published.status).toBe(200); + const other = h.request("@alpha/foundation", tarOf(skillFiles()) as BodyInit, { key: res.body.token }); + expect((await publish(other, { scope: "@alpha", slug: "foundation" }, deps)).status).toBe(403); + }); + + it.each([ + ["another repository id", { repository_id: "43" }], + ["another workflow", { workflow_ref: "nextlevelbuilder/skillx/.github/workflows/other.yml@refs/heads/main" }], + ["another ref", { ref: "refs/heads/dev", workflow_ref: "nextlevelbuilder/skillx/.github/workflows/publish-agentkit-foundation.yml@refs/heads/dev" }], + ["a fork with the same name", { repository: "nextlevelbuilder/skillx", repository_id: "1000" }], + ])("refuses %s as not trusted", async (_label, override) => { + await configure(TRUST); + expect((await exchange(await jwt({ ...baseClaims(), ...override }))).body.error.code).toBe("oidc.not_trusted"); + }); + + it("refuses pull request events and unknown publishers", async () => { + await configure(TRUST); + expect((await exchange(await jwt({ ...baseClaims(), event_name: "pull_request_target" }))).body.error.code).toBe("oidc.event_forbidden"); + expect((await exchange(await jwt(baseClaims()), "@alpha")).body.error.code).toBe("oidc.not_trusted"); + }); + + it.each([ + ["another audience", { aud: "sigstore" }], + ["another issuer", { iss: "https://evil.example" }], + ["an expired token", { exp: NOW.getTime() / 1000 - 1 }], + ["a future token", { iat: NOW.getTime() / 1000 + 600, nbf: NOW.getTime() / 1000 + 600, exp: NOW.getTime() / 1000 + 900 }], + ["a missing claim", { repository_id: undefined }], + ])("rejects %s as an invalid token", async (_label, override) => { + await configure(TRUST); + const res = await exchange(await jwt({ ...baseClaims(), ...override })); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("oidc.invalid_token"); + }); + + it("rejects a bad signature, another algorithm and an unknown key", async () => { + await configure(TRUST); + const other = (await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["sign"])) as CryptoKeyPair; + expect((await exchange(await jwt(baseClaims(), undefined, other.privateKey))).status).toBe(401); + expect((await exchange(await jwt(baseClaims(), { alg: "none", kid: "k1" }))).status).toBe(401); + expect((await exchange(await jwt(baseClaims(), { alg: "RS256", kid: "k9" }))).status).toBe(401); + expect((await exchange("not.a.jwt")).status).toBe(401); + }); + + it("is 503 without the token secret", async () => { + await configure(TRUST); + expect((await exchange(await jwt(baseClaims()), "@bestagentkits", {})).body.error.code).toBe("oidc.unavailable"); + }); +}); diff --git a/apps/web/tests/releases/release-download-url.test.ts b/apps/web/tests/releases/release-download-url.test.ts new file mode 100644 index 0000000..0fd940c --- /dev/null +++ b/apps/web/tests/releases/release-download-url.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { action as mintAction } from "~/routes/api.v1.release-download-url"; +import { loader as artifactLoader } from "~/routes/api.v1.release-artifact"; +import { DOWNLOAD_URL_TTL_MS, serveDownloadRedeem } from "~/lib/releases/release-download-url"; +import { envOf, publish, sql, useStateHarness } from "./release-state-helpers"; +import type { Harness } from "./release-state-helpers"; + +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +const SECRET = "test-token-secret-0123456789abcdef"; +let h: Harness; +let readerKey: string; + +beforeEach(async () => { + h = await useStateHarness(); + session.user = null; + readerKey = await h.createKey("user-r", null); +}); + +async function mint(digest: string, key?: string, env: Record = { SKILLX_TOKEN_SECRET: SECRET }) { + const headers = new Headers(); + if (key !== undefined) headers.set("Authorization", `Bearer ${key}`); + const request = new Request(`https://skillx.test/api/v1/releases/${digest}/artifact/url`, { method: "POST", headers }); + const res = await mintAction({ request, params: { digest }, context: { cloudflare: { env: envOf(env) } } } as never); + return { status: res.status, headers: res.headers, body: (await res.json()) as Record }; +} + +const redeem = (url: string, now = new Date(), env: Record = { SKILLX_TOKEN_SECRET: SECRET }) => { + const token = url.slice(url.lastIndexOf("/") + 1); + return serveDownloadRedeem(new Request(url), envOf(env) as never, { token }, () => now); +}; + +const artifactBytes = async (digest: string) => { + const res = await artifactLoader({ request: new Request(`https://skillx.test/api/v1/releases/${digest}/artifact`), params: { digest }, context: { cloudflare: { env: envOf() } } } as never); + return new Uint8Array(await res.arrayBuffer()); +}; + +const makePrivate = () => sql("UPDATE packages SET visibility = 'private' WHERE name = '@alpha/demo'"); +const grant = (subject: string) => + sql("INSERT INTO entitlements (id, subject_id, package_id, release_id, source, granted_at, revoked_at) SELECT ?, ?, id, NULL, 'grant', 1, NULL FROM packages WHERE name = '@alpha/demo'", `ent-${subject}`, subject); + +describe("signed artifact download URLs", () => { + it("mints a 5 minute URL for a public release that streams the same bytes, no-store", async () => { + const digest = await publish(); + const minted = await mint(digest); + expect(minted.status).toBe(200); + expect(minted.headers.get("Cache-Control")).toBe("no-store"); + expect(minted.body).toMatchObject({ identity: "@alpha/demo", version: "1.0.0", releaseDigest: digest }); + expect(minted.body.url).toMatch(/^https:\/\/skillx\.test\/api\/v1\/downloads\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + const res = await redeem(minted.body.url); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(await artifactBytes(digest)); + }); + + it("answers 404 to an expired, tampered or unconfigured token", async () => { + const digest = await publish(); + const { url } = (await mint(digest)).body; + expect((await redeem(url, new Date(Date.now() + DOWNLOAD_URL_TTL_MS + 1000))).status).toBe(404); + const tampered = url.replace(/.$/, (c: string) => (c === "A" ? "B" : "A")); + expect((await redeem(tampered)).status).toBe(404); + expect((await redeem(url, new Date(), { SKILLX_TOKEN_SECRET: "another-secret-0123456789abcdefghij" })).status).toBe(404); + expect((await redeem(url, new Date(), {})).status).toBe(404); + }); + + it("only mints for an entitled viewer of a private package and re-checks access on redemption", async () => { + const digest = await publish(); + makePrivate(); + expect((await mint(digest)).status).toBe(404); + expect((await mint(digest, readerKey)).status).toBe(404); + grant("user-r"); + const minted = await mint(digest, readerKey); + expect(minted.status).toBe(200); + expect((await redeem(minted.body.url)).status).toBe(200); + sql("UPDATE entitlements SET revoked_at = 2"); + expect((await redeem(minted.body.url)).status).toBe(404); + }); + + it("stops an issued URL once the release is quarantined", async () => { + const digest = await publish(); + const { url } = (await mint(digest)).body; + sql("UPDATE package_releases SET release_state = 'quarantined', quarantined_at = 5"); + expect((await redeem(url)).status).not.toBe(200); + }); + + it("is 503 without SKILLX_TOKEN_SECRET and 404 with the releases API off", async () => { + const digest = await publish(); + expect((await mint(digest, undefined, {})).body.error.code).toBe("download_url.unavailable"); + const { url } = (await mint(digest)).body; + expect((await redeem(url, new Date(), { SKILLX_TOKEN_SECRET: SECRET, SKILLX_RELEASES_API: "0" })).status).toBe(404); + }); +}); diff --git a/apps/web/tests/releases/release-idempotency.test.ts b/apps/web/tests/releases/release-idempotency.test.ts new file mode 100644 index 0000000..552cff4 --- /dev/null +++ b/apps/web/tests/releases/release-idempotency.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { IDEMPOTENCY_TTL_MS, publishRelease } from "~/lib/releases/release-idempotency"; +import { NOW, createHarness, file, tarOf } from "./release-harness"; +import type { UploadOptions } from "./release-harness"; + +type Harness = Awaited>; +let h: Harness; + +beforeEach(async () => { + h = await createHarness(); +}); + +const skill = (body = "# Demo\n") => tarOf([file("SKILL.md", `---\nname: demo\ndescription: Demo\n---\n${body}`)]); + +async function send(key: string | null, bytes: Uint8Array, options: UploadOptions = {}, now = NOW) { + const req = h.request("@alpha/demo", bytes as BodyInit, options); + if (key !== null) req.headers.set("Idempotency-Key", key); + const res = await publishRelease(req, { scope: "@alpha", slug: "demo" }, { ...h.deps, now: () => now }); + return { status: res.status, headers: res.headers, text: await res.text() }; +} + +const rows = () => h.d1.raw.prepare("SELECT idempotency_key, status, response_status FROM release_publish_idempotency").all() as Array>; + +describe("release publish Idempotency-Key", () => { + it("replays the exact stored answer for the same key and request", async () => { + const first = await send("run-1", skill()); + expect(first.status).toBe(200); + expect(JSON.parse(first.text).created).toBe(true); + const again = await send("run-1", skill()); + expect(again.status).toBe(200); + expect(again.text).toBe(first.text); + expect(again.headers.get("Idempotent-Replayed")).toBe("true"); + expect(h.releaseRows()).toHaveLength(1); + expect(rows()).toEqual([{ idempotency_key: "run-1", status: "complete", response_status: 200 }]); + }); + + it("refuses the same key with another body or other metadata (422) and writes nothing", async () => { + await send("run-1", skill()); + const otherBody = await send("run-1", skill("# Changed\n")); + expect(otherBody.status).toBe(422); + expect(JSON.parse(otherBody.text).error.code).toBe("idempotency.key_reused"); + const otherVersion = await send("run-1", skill(), { version: "1.0.1" }); + expect(otherVersion.status).toBe(422); + expect(h.releaseRows()).toHaveLength(1); + }); + + it("answers 409 while the first request with the key is still pending", async () => { + await send("run-1", skill()); + h.d1.raw.prepare("UPDATE release_publish_idempotency SET status = 'pending', response_status = NULL, response_body = NULL, created_at = ?").run(NOW.getTime()); + const res = await send("run-1", skill()); + expect(res.status).toBe(409); + expect(JSON.parse(res.text).error.code).toBe("idempotency.in_progress"); + }); + + it("lets the same request take over a stale pending claim, and any request reuse an expired key", async () => { + await send("run-1", skill()); + h.d1.raw.prepare("UPDATE release_publish_idempotency SET status = 'pending', created_at = ?").run(NOW.getTime() - 6 * 60_000); + const takeover = await send("run-1", skill()); + expect(takeover.status).toBe(200); + expect(JSON.parse(takeover.text).created).toBe(false); + const later = new Date(NOW.getTime() + IDEMPOTENCY_TTL_MS + 1); + const reused = await send("run-1", skill("# Next\n"), { version: "1.1.0" }, later); + expect(reused.status).toBe(200); + expect(JSON.parse(reused.text).version).toBe("1.1.0"); + }); + + it("stores deterministic 4xx answers, but never claims a key for a rejected credential", async () => { + const conflict = await send("v-reuse", skill("# A\n")); + expect(conflict.status).toBe(200); + const clash = await send("v-clash", skill("# B\n")); + expect(clash.status).toBe(409); + expect((await send("v-clash", skill("# B\n"))).text).toBe(clash.text); + const noAuth = await send("v-anon", skill(), { key: null }); + expect(noAuth.status).toBe(401); + expect(rows().map((r) => r.idempotency_key)).not.toContain("v-anon"); + }); + + it("rejects a malformed key (400) and keeps the keys of two publishers apart", async () => { + const bad = await send("has space", skill()); + expect(bad.status).toBe(400); + expect(JSON.parse(bad.text).error.code).toBe("idempotency.invalid_key"); + expect((await send(null, skill())).status).toBe(200); + const req = h.request("@bestagentkits/demo", skill() as BodyInit); + req.headers.set("Idempotency-Key", "run-1"); + await send("run-1", skill()); + const res = await publishRelease(req, { scope: "@bestagentkits", slug: "demo" }, h.deps); + expect(res.status).toBe(200); + expect(res.headers.get("Idempotent-Replayed")).toBeNull(); + }); +}); diff --git a/apps/web/tests/releases/release-promote.test.ts b/apps/web/tests/releases/release-promote.test.ts new file mode 100644 index 0000000..6f25d65 --- /dev/null +++ b/apps/web/tests/releases/release-promote.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { action as promoteAction } from "~/routes/api.v1.release-promote"; +import { releasePublishScope } from "~/lib/auth/api-key-scopes"; +import { mintOidcPublishToken } from "~/lib/releases/trusted-publish-token"; +import { tarOf } from "./release-harness"; +import { ADMIN, audit, change, envOf, expectError, publish, skillFiles, sql, useStateHarness } from "./release-state-helpers"; +import type { Harness } from "./release-state-helpers"; + +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +const SECRET = "test-token-secret-0123456789abcdef"; +let h: Harness; +let ownerKey: string; +let otherKey: string; + +beforeEach(async () => { + h = await useStateHarness(); + session.user = null; + ownerKey = await h.createKey("user-a", null); + otherKey = await h.createKey("user-b", null); +}); + +async function promote(body: unknown, auth: { key?: string; admin?: string } = {}, name = "@alpha/demo", version = "1.0.0") { + const [scope, slug] = name.split("/") as [string, string]; + const headers = new Headers({ "Content-Type": "application/json" }); + if (auth.key !== undefined) headers.set("Authorization", `Bearer ${auth.key}`); + if (auth.admin !== undefined) headers.set("X-Admin-Secret", auth.admin); + const request = new Request(`https://skillx.test/api/v1/packages/${name}/releases/${version}/promote`, { method: "POST", headers, body: JSON.stringify(body) }); + const res = await promoteAction({ request, params: { scope, slug, version }, context: { cloudflare: { env: envOf({ SKILLX_TOKEN_SECRET: SECRET }) } } } as never); + return { status: res.status, body: (await res.json()) as Record }; +} + +const channelOf = (version = "1.0.0") => + (h.d1.raw.prepare("SELECT r.channel AS c FROM package_releases r JOIN packages p ON p.id = r.package_id WHERE p.name = '@alpha/demo' AND r.version = ?").get(version) as { c: string }).c; + +async function publishBeta() { + const res = await h.upload("@alpha/demo", tarOf(skillFiles()), { + metadata: { version: "1.0.0", channel: "beta" }, + }); + expect(res.status).toBe(200); +} + +describe("release promote", () => { + it("lets the scoped publish key promote beta -> stable, audited as release_channel", async () => { + await publishBeta(); + const res = await promote({ to: "stable", reason: "soak passed" }, { key: h.alphaKey }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ identity: "@alpha/demo", version: "1.0.0", from: "beta", to: "stable", channel: "stable" }); + expect(channelOf()).toBe("stable"); + expect(audit().at(-1)).toMatchObject({ subject_kind: "release_channel", actor_kind: "publisher_key", from_state: "beta", to_state: "stable", outcome: "applied", reason: "soak passed" }); + }); + + it("forbids a publisher credential from demoting, but lets the owner and the admin", async () => { + await publish(); + expectError(await promote({ to: "beta" }, { key: h.alphaKey }), 403, "promote.demotion_forbidden"); + expect(audit().at(-1)).toMatchObject({ subject_kind: "release_channel", outcome: "denied" }); + expect((await promote({ to: "beta" }, { key: ownerKey })).status).toBe(200); + expect((await promote({ to: "alpha" }, { admin: ADMIN })).status).toBe(200); + expect(channelOf()).toBe("alpha"); + }); + + it("accepts an OIDC publish token of the handle, refuses one for another handle", async () => { + await publishBeta(); + sql("INSERT INTO trusted_publishers (id, publisher_id, provider, repository, repository_id, workflow_path, state, created_at) VALUES ('tp-1', 'pub-alpha', 'github', 'o/r', '1', '.github/workflows/p.yml', 'active', 1)"); + const { token } = await mintOidcPublishToken(SECRET, { handle: "alpha", publisherId: "pub-alpha", trustedPublisherId: "tp-1", jti: "j1" }, new Date()); + expect((await promote({ to: "stable" }, { key: token })).status).toBe(200); + const { token: other } = await mintOidcPublishToken(SECRET, { handle: "beta", publisherId: "pub-beta", trustedPublisherId: "tp-1", jti: "j2" }, new Date()); + expect((await promote({ to: "beta" }, { key: other })).status).toBe(403); + sql("UPDATE trusted_publishers SET state = 'revoked'"); + expect((await promote({ to: "beta" }, { key: token })).status).toBe(401); + }); + + it("refuses non-owners, the same channel, stale from, and unpublished releases", async () => { + await publish(); + expectError(await promote({ to: "beta" }, { key: otherKey }), 403, "transition.not_owner"); + expectError(await promote({ to: "stable" }, { key: ownerKey }), 422, "promote.same_channel"); + expectError(await promote({ from: "beta", to: "alpha" }, { key: ownerKey }), 409, "state_conflict"); + expect((await change("@alpha/demo", "1.0.0", { from: "published", to: "yanked" }, { key: ownerKey })).status).toBe(200); + expectError(await promote({ to: "beta" }, { key: ownerKey }), 409, "promote.not_published"); + expect(channelOf()).toBe("stable"); + }); + + it("validates the body and the credential", async () => { + await publish(); + expectError(await promote({ to: "nightly" }, { key: ownerKey }), 400, "invalid_request"); + expectError(await promote({ to: "beta", extra: 1 }, { key: ownerKey }), 400, "invalid_request"); + expect((await promote({ to: "beta" })).status).toBe(401); + const otherScoped = await h.createKey("user-b", [releasePublishScope("beta")]); + expect((await promote({ to: "beta" }, { key: otherScoped })).status).toBe(403); + }); +}); diff --git a/apps/web/tests/releases/release-review.test.ts b/apps/web/tests/releases/release-review.test.ts new file mode 100644 index 0000000..48646c6 --- /dev/null +++ b/apps/web/tests/releases/release-review.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { action as reviewAction } from "~/routes/api.v1.release-review"; +import { loader as queueLoader } from "~/routes/api.v1.admin-quarantine"; +import { ADMIN, audit, change, envOf, expectError, publish, sql, stateOf, useStateHarness } from "./release-state-helpers"; +import type { Harness } from "./release-state-helpers"; + +const session = vi.hoisted(() => ({ user: null as { id: string } | null })); +vi.mock("~/lib/auth/session-helpers", () => ({ + getSession: async () => (session.user === null ? null : { user: session.user }), +})); + +let h: Harness; +let ownerKey: string; + +beforeEach(async () => { + h = await useStateHarness(); + session.user = null; + ownerKey = await h.createKey("user-a", null); +}); + +const admin = { admin: ADMIN }; + +async function review(body: unknown, auth: { key?: string; admin?: string } = admin) { + const headers = new Headers({ "Content-Type": "application/json" }); + if (auth.key !== undefined) headers.set("Authorization", `Bearer ${auth.key}`); + if (auth.admin !== undefined) headers.set("X-Admin-Secret", auth.admin); + const request = new Request("https://skillx.test/api/v1/packages/@alpha/demo/releases/1.0.0/review", { method: "POST", headers, body: JSON.stringify(body) }); + const res = await reviewAction({ request, params: { scope: "@alpha", slug: "demo", version: "1.0.0" }, context: { cloudflare: { env: envOf() } } } as never); + return { status: res.status, body: (await res.json()) as Record }; +} + +async function queue(auth: { key?: string; admin?: string } = admin) { + const headers = new Headers(); + if (auth.key !== undefined) headers.set("Authorization", `Bearer ${auth.key}`); + if (auth.admin !== undefined) headers.set("X-Admin-Secret", auth.admin); + const res = await queueLoader({ request: new Request("https://skillx.test/api/v1/admin/quarantine", { headers }), params: {}, context: { cloudflare: { env: envOf() } } } as never); + return { status: res.status, body: (await res.json()) as Record }; +} + +async function quarantine() { + await publish(); + expect((await change("@alpha/demo", "1.0.0", { from: "published", to: "quarantined", reason: "report" }, admin)).status).toBe(200); +} + +const reviews = () => h.d1.raw.prepare("SELECT decision, actor_kind, reason FROM release_reviews ORDER BY rowid").all(); + +describe("quarantine review", () => { + it("lists the queue and records a reject without changing the release", async () => { + await quarantine(); + const listed = await queue(); + expect(listed.status).toBe(200); + expect(listed.body.releases).toMatchObject([{ identity: "@alpha/demo", version: "1.0.0", lastReview: null }]); + const res = await review({ decision: "reject", reason: "confirmed malicious" }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ decision: "reject", state: "quarantined" }); + expect(stateOf("1.0.0")).toBe("quarantined"); + expect(reviews()).toEqual([{ decision: "rejected", actor_kind: "admin", reason: "confirmed malicious" }]); + expect((await queue()).body.releases[0].lastReview).toMatchObject({ decision: "rejected", reason: "confirmed malicious" }); + }); + + it("approves by publishing again, with both edges audited and one review row", async () => { + await quarantine(); + const res = await review({ decision: "approve", reason: "false positive" }); + expect(res.status).toBe(200); + expect(res.body.state).toBe("published"); + expect(stateOf("1.0.0")).toBe("published"); + expect(audit().slice(-2).map((r) => [r.from_state, r.to_state, r.outcome])).toEqual([ + ["quarantined", "yanked", "applied"], + ["yanked", "published", "applied"], + ]); + expect(reviews()).toEqual([{ decision: "approved", actor_kind: "admin", reason: "false positive" }]); + expect((await queue()).body.releases).toEqual([]); + }); + + it("answers 409 for a release that is not quarantined and writes no review", async () => { + await publish(); + expectError(await review({ decision: "approve" }), 409, "state_conflict"); + expectError(await review({ decision: "reject" }), 409, "state_conflict"); + expect(reviews()).toEqual([]); + }); + + it("is admin only and validates the decision", async () => { + await quarantine(); + expectError(await review({ decision: "approve" }, { key: ownerKey }), 403, "review.admin_only"); + expectError(await queue({ key: ownerKey }), 403, "review.admin_only"); + expect((await review({ decision: "approve" }, {})).status).toBe(401); + expectError(await review({ decision: "maybe" }), 400, "invalid_request"); + expect(stateOf("1.0.0")).toBe("quarantined"); + }); + + it("keeps review rows append-only", async () => { + await quarantine(); + await review({ decision: "reject" }); + expect(() => sql("UPDATE release_reviews SET decision = 'approved'")).toThrow(/append-only/); + expect(() => sql("DELETE FROM release_reviews")).toThrow(/append-only/); + }); +}); diff --git a/apps/web/workers/app.ts b/apps/web/workers/app.ts index d6f0c60..027bdab 100644 --- a/apps/web/workers/app.ts +++ b/apps/web/workers/app.ts @@ -21,6 +21,13 @@ declare global { * vars that are missing from the config, and only secrets survive it. */ SKILLX_REGISTRY_REVOKED_KEY_IDS?: string; + /** + * HMAC secret (at least 32 characters) for short-lived tokens: OIDC publish tokens + * (`sxo_`) and signed artifact download URLs. Secret; unset disables both features. + */ + SKILLX_TOKEN_SECRET?: string; + /** Audience a GitHub Actions OIDC token must carry; defaults to `skillx`. */ + SKILLX_OIDC_AUDIENCE?: string; } } diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/import.ts similarity index 100% rename from packages/cli/src/commands/publish.ts rename to packages/cli/src/commands/import.ts From 0cae8502169278415ea584c1bb7c354ae5b091b6 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:58:06 +0700 Subject: [PATCH 02/11] feat(cli): rename publish to import and add validate, pack, publish and promote publish owner/repo keeps working as a deprecated alias of import with a stderr warning; release pack/publish remain aliases. Release commands share the JSON envelope and standard exit codes, and publish supports --idempotency-key and --oidc. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/built-cli.test.ts | 53 +++++- packages/cli/src/commands/import.ts | 32 ++-- packages/cli/src/commands/pack.ts | 44 +++++ packages/cli/src/commands/promote.ts | 64 +++++++ packages/cli/src/commands/publish.ts | 46 +++++ .../cli/src/commands/release-commands.test.ts | 64 +++++++ packages/cli/src/commands/release-pack.ts | 24 --- packages/cli/src/commands/release-publish.ts | 164 ++++++++++-------- packages/cli/src/commands/validate.ts | 91 ++++++++++ packages/cli/src/index.ts | 8 + packages/cli/src/lib/release-cli.ts | 114 ++++++++++++ 11 files changed, 585 insertions(+), 119 deletions(-) create mode 100644 packages/cli/src/commands/pack.ts create mode 100644 packages/cli/src/commands/promote.ts create mode 100644 packages/cli/src/commands/publish.ts create mode 100644 packages/cli/src/commands/release-commands.test.ts create mode 100644 packages/cli/src/commands/validate.ts create mode 100644 packages/cli/src/lib/release-cli.ts diff --git a/packages/cli/src/built-cli.test.ts b/packages/cli/src/built-cli.test.ts index afe71ac..3483d7b 100644 --- a/packages/cli/src/built-cli.test.ts +++ b/packages/cli/src/built-cli.test.ts @@ -81,15 +81,15 @@ describe("built cli", () => { it("packs a skill with YAML frontmatter through the skill-tree reader", async () => { const out = join(root, "smoke.tar"); const { stdout } = await cli("release", "pack", skillDir, "--out", out, "--json"); - const summary = JSON.parse(stdout) as { releaseDigest: string; artifactDigest: string; fileCount: number }; + const summary = (JSON.parse(stdout) as { ok: true; data: { releaseDigest: string; artifactDigest: string; fileCount: number } }).data; expect(summary.releaseDigest).toMatch(/^sha256:[0-9a-f]{64}$/); expect(summary.fileCount).toBe(2); expect(`sha256:${createHash("sha256").update(readFileSync(out)).digest("hex")}`).toBe(summary.artifactDigest); }); it("publishes a release to a loopback stub registry", async () => { - const packDigest = (JSON.parse((await cli("release", "pack", skillDir, "--out", join(root, "pub.tar"), "--json")).stdout) as { releaseDigest: string }).releaseDigest; - const received: { url?: string; auth?: string; type?: string; digest?: string } = {}; + const packDigest = (JSON.parse((await cli("pack", skillDir, "--out", join(root, "pub.tar"), "--json")).stdout) as { data: { releaseDigest: string } }).data.releaseDigest; + const received: { url?: string; auth?: string; type?: string; digest?: string; idempotency?: string } = {}; const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); @@ -100,6 +100,7 @@ describe("built cli", () => { auth: req.headers.authorization, type: req.headers["content-type"], digest: `sha256:${createHash("sha256").update(body).digest("hex")}`, + idempotency: req.headers["idempotency-key"], }); const meta = JSON.parse(Buffer.from(String(req.headers["x-skillx-release"]), "base64").toString("utf8")) as { version: string }; res.writeHead(201, { "Content-Type": "application/json" }); @@ -111,18 +112,56 @@ describe("built cli", () => { try { const { port } = server.address() as AddressInfo; const { stdout } = await cli( - "release", "publish", skillDir, + "publish", skillDir, "--name", "@smoke/smoke-skill", "--release-version", "1.0.0", "--repo", "https://github.com/example/smoke", "--registry", `http://127.0.0.1:${port}`, + "--idempotency-key", "smoke-run-1", "--json", ); - const result = JSON.parse(stdout) as { releaseDigest: string; artifactDigest: string; state: string }; + const result = (JSON.parse(stdout) as { ok: true; data: { releaseDigest: string; artifactDigest: string; state: string } }).data; expect(result).toMatchObject({ releaseDigest: packDigest, artifactDigest: received.digest, state: "published" }); - expect(received).toMatchObject({ url: "/api/v1/packages/@smoke/smoke-skill/releases", auth: "Bearer sx_test_smoke_key", type: "application/x-tar" }); + expect(received).toMatchObject({ url: "/api/v1/packages/@smoke/smoke-skill/releases", auth: "Bearer sx_test_smoke_key", type: "application/x-tar", idempotency: "smoke-run-1" }); } finally { await new Promise((resolve) => server.close(() => resolve())); } }); -}); + + it("validates and packs the same tree to the same digest on every run", async () => { + const runs = []; + for (const i of [1, 2]) { + const validated = JSON.parse((await cli("validate", skillDir, "--name", "@smoke/smoke-skill", "--release-version", "1.0.0", "--json")).stdout); + const packed = JSON.parse((await cli("pack", skillDir, "--out", join(root, `twice-${i}.tar`), "--json")).stdout); + expect(validated).toMatchObject({ ok: true, data: { valid: true, skill: { name: "smoke-skill" } } }); + runs.push([validated.data.releaseDigest, packed.data.releaseDigest, packed.data.artifactDigest]); + } + expect(runs[0][0]).toBe(runs[0][1]); + expect(runs[1]).toEqual(runs[0]); + expect(readFileSync(join(root, "twice-1.tar"))).toEqual(readFileSync(join(root, "twice-2.tar"))); + }); + + it("answers usage (2) and auth (5) failures with an envelope and exit code", async () => { + const bad = await cli("validate", skillDir, "--release-version", "v1", "--json").catch((e) => e); + expect(bad.code).toBe(2); + expect(JSON.parse(bad.stdout)).toMatchObject({ ok: false, error: { code: "usage_error" } }); + const noKey = await execFileAsync(process.execPath, [BIN, "publish", skillDir, "--name", "@smoke/s", "--release-version", "1.0.0", "--registry", "http://127.0.0.1:9", "--json"], { + cwd: root, + env: { ...childEnv, SKILLX_API_KEY: "" }, + encoding: "utf8", + }).catch((e) => e); + expect(noKey.code).toBe(5); + expect(JSON.parse(noKey.stdout).error.code).toBe("auth_error"); + }); + + it("keeps `publish owner/repo` as a deprecated import and exposes the aliases", async () => { + const { stdout, stderr } = await cli("publish", "example/smoke", "--dry-run"); + expect(stderr).toContain("deprecated"); + expect(stderr).toContain("skillx import"); + expect(stdout).toContain("example/smoke"); + expect((await cli("import", "example/smoke", "--dry-run")).stderr).not.toContain("deprecated"); + const help = (await cli("--help")).stdout; + for (const command of ["import", "validate", "pack", "publish", "promote", "release"]) expect(help).toContain(command); + expect((await cli("promote", "--help")).stdout).toContain("--to "); + }); +}); \ No newline at end of file diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 2027729..e7337f2 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -40,8 +40,9 @@ function detectGitRemote(): { owner: string; repo: string } | null { } } -/** Parse explicit "owner/repo" argument */ -function parseRepoArg(arg: string): { owner: string; repo: string } | null { +/** Parse an explicit "owner/repo" or GitHub URL argument */ +export function parseRepoArg(arg: string): { owner: string; repo: string } | null { + if (arg.includes('github.com')) return parseGitRemoteUrl(arg); const parts = arg.split('/'); if (parts.length === 2 && parts[0] && parts[1]) { return { owner: parts[0], repo: parts[1] }; @@ -49,13 +50,14 @@ function parseRepoArg(arg: string): { owner: string; repo: string } | null { return null; } -export const publishCommand = new Command('publish') - .description('Publish skills from a GitHub repo to SkillX marketplace') - .argument('[repo]', 'owner/repo (auto-detects from git remote if omitted)') - .option('-p, --path ', 'Specific skill subfolder path') - .option('-s, --scan', 'Scan entire repo for all SKILL.md files') - .option('--dry-run', 'Show what would be published without calling API') - .action(async (repoArg: string | undefined, options: { path?: string; scan?: boolean; dryRun?: boolean }) => { +export interface ImportOptions { + path?: string; + scan?: boolean; + dryRun?: boolean; +} + +/** Registers the skills of a GitHub repository with the marketplace (POST /api/skills/register). */ +export async function runImport(repoArg: string | undefined, options: ImportOptions): Promise { try { // Require API key const apiKey = getApiKey(); @@ -79,7 +81,7 @@ export const publishCommand = new Command('publish') remote = detectGitRemote(); if (!remote) { console.error(chalk.red('\nCould not detect GitHub repo from git remote.')); - console.error(chalk.dim('Specify explicitly: skillx publish owner/repo')); + console.error(chalk.dim('Specify explicitly: skillx import owner/repo')); process.exit(1); } console.log(chalk.dim(`Detected repo: ${remote.owner}/${remote.repo}`)); @@ -147,7 +149,15 @@ export const publishCommand = new Command('publish') } process.exit(1); } - }); +} + +export const importCommand = new Command('import') + .description('Import (register) skills from a GitHub repo into the SkillX marketplace') + .argument('[repo]', 'owner/repo or GitHub URL (auto-detects from git remote if omitted)') + .option('-p, --path ', 'Specific skill subfolder path') + .option('-s, --scan', 'Scan entire repo for all SKILL.md files') + .option('--dry-run', 'Show what would be imported without calling API') + .action(runImport); function displayResult(res: RegisterResponse, owner: string, repo: string): void { // Single skill mode diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts new file mode 100644 index 0000000..a369283 --- /dev/null +++ b/packages/cli/src/commands/pack.ts @@ -0,0 +1,44 @@ +import { Command } from 'commander'; +import { writeFileSync } from 'node:fs'; +import chalk from 'chalk'; +import { CliError, runCommand } from '../lib/release-cli.js'; +import { packRelease, packSummary, warnPackNotes } from './release-pack.js'; +import type { PackedRelease } from './release-pack.js'; + +/** Packs, turning a source the registry would reject into a usage error (exit 2). */ +export async function packOrFail(input: string, packagePath?: string): Promise { + try { + const packed = await packRelease(input, { packagePath }); + warnPackNotes(packed); + return packed; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError('usage_error', `cannot pack ${input}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** `skillx pack` (and its alias `skillx release pack`). */ +export function createPackCommand(): Command { + return new Command('pack') + .description('Pack a skill from git at an exact commit into a deterministic release tar') + .argument('', 'package directory (packs HEAD) or git ref (with --path)') + .requiredOption('-o, --out ', 'output tar file') + .option('-p, --path ', 'package directory inside the repository, for a git ref') + .option('--json', 'print the result as a JSON envelope') + .action(async (input: string, options: { out: string; path?: string; json?: boolean }) => { + await runCommand( + 'pack', + options.json, + async () => { + const packed = await packOrFail(input, options.path); + writeFileSync(options.out, packed.tar); + return { ...packSummary(packed), out: options.out }; + }, + (summary) => { + console.log(summary.releaseDigest); + console.error(chalk.dim(`commit ${summary.commit}${summary.path ? ` path ${summary.path}` : ''}`)); + console.error(chalk.dim(`${summary.fileCount} files, ${summary.treeSize} bytes -> ${summary.out} (${summary.artifactDigest})`)); + }, + ); + }); +} diff --git a/packages/cli/src/commands/promote.ts b/packages/cli/src/commands/promote.ts new file mode 100644 index 0000000..75dd884 --- /dev/null +++ b/packages/cli/src/commands/promote.ts @@ -0,0 +1,64 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { parsePackageName, publishCredential, registryFetch, runCommand, usageError } from '../lib/release-cli.js'; +import { resolveRegistry } from './release-publish.js'; + +const CHANNELS = ['stable', 'beta', 'alpha']; +const TARGET = /^(@[^@/\s]+\/[^@/\s]+)@([^@\s]+)$/; + +interface PromoteOptions { + to: string; + from?: string; + reason?: string; + registry?: string; + oidc?: boolean; + oidcAudience?: string; + json?: boolean; +} + +/** `@publisher/slug@version` split into its package name and version. */ +export function parseReleaseTarget(raw: string): { handle: string; slug: string; name: string; version: string } { + const match = TARGET.exec(raw); + if (!match) throw usageError(`expected @publisher/slug@version, got ${JSON.stringify(raw)}`); + return { ...parsePackageName(match[1]), version: match[2] }; +} + +/** + * Moves a published release to another channel (`POST .../promote`). A + * publish credential (scoped key or `--oidc`) may only promote (alpha -> beta + * -> stable); the package owner's account key may also demote. Every attempt + * is audited by the registry. + */ +export async function runPromote(target: string, options: PromoteOptions) { + const release = parseReleaseTarget(target); + for (const [flag, value] of [['--to', options.to], ['--from', options.from]] as const) { + if (value !== undefined && !CHANNELS.includes(value)) throw usageError(`${flag} must be one of: ${CHANNELS.join(', ')}`); + } + const base = resolveRegistry(options.registry); + const credential = await publishCredential(base, release.handle, options); + const body: Record = { to: options.to }; + if (options.from !== undefined) body.from = options.from; + if (options.reason !== undefined) body.reason = options.reason; + return registryFetch(`${base}/api/v1/packages/@${release.handle}/${release.slug}/releases/${encodeURIComponent(release.version)}/promote`, { + method: 'POST', + headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +export const promoteCommand = new Command('promote') + .description('Move a published release to another channel, e.g. beta -> stable (audited)') + .argument('', '@publisher/slug@version') + .requiredOption('--to ', 'stable | beta | alpha') + .option('--from ', 'expected current channel (the move fails if it changed)') + .option('--reason ', 'reason recorded in the audit log') + .option('--registry ', 'registry base URL (default: configured base URL)') + .option('--oidc', 'authenticate with the GitHub Actions OIDC token (trusted publishing)') + .option('--oidc-audience ', 'audience requested for the OIDC token', 'skillx') + .option('--json', 'print the result as a JSON envelope') + .action(async (target: string, options: PromoteOptions) => { + await runCommand('promote', options.json, () => runPromote(target, options), (result) => { + console.log(chalk.bold.green(`${target}: ${String(result.from)} -> ${String(result.to)}`)); + console.log(` releaseDigest: ${chalk.cyan(String(result.releaseDigest))}`); + }); + }); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 0000000..2355b4b --- /dev/null +++ b/packages/cli/src/commands/publish.ts @@ -0,0 +1,46 @@ +import { Command } from 'commander'; +import { existsSync } from 'node:fs'; +import chalk from 'chalk'; +import { runCommand } from '../lib/release-cli.js'; +import { runImport } from './import.js'; +import { renderPublished, runReleasePublish, withReleasePublishOptions } from './release-publish.js'; +import type { ReleasePublishOptions } from './release-publish.js'; + +const GITHUB_URL = /^(?:https?:\/\/|git@)(?:www\.)?github\.com[/:]/i; +const OWNER_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/; + +export const IMPORT_DEPRECATION = + 'warning: `skillx publish ` is deprecated and will be removed; use `skillx import `. `skillx publish ` now publishes a release.'; + +/** + * Which `publish` the caller means: a GitHub repository (the old marketplace + * import) or a package to release. A path that exists, or any release option, + * is always a release; only a GitHub URL or an `owner/repo` that is not a + * local path is an import. + */ +export function publishMode(source: string | undefined, options: Partial): 'import' | 'release' { + if (options.name !== undefined || options.releaseVersion !== undefined) return 'release'; + if (source === undefined) return 'import'; + if (existsSync(source)) return 'release'; + return GITHUB_URL.test(source) || OWNER_REPO.test(source) ? 'import' : 'release'; +} + +interface PublishOptions extends ReleasePublishOptions { + scan?: boolean; + dryRun?: boolean; +} + +export const publishCommand = withReleasePublishOptions( + new Command('publish') + .description('Publish a package directory as an immutable SkillX release (`` still imports, deprecated: use `skillx import`)') + .argument('[source]', 'package directory (packs HEAD) or git ref (with --path); owner/repo or GitHub URL = deprecated import') + .option('-s, --scan', 'import only (deprecated): scan the whole repo for SKILL.md files') + .option('--dry-run', 'import only (deprecated): show what would be imported'), +).action(async (source: string | undefined, options: PublishOptions) => { + if (publishMode(source, options) === 'import') { + console.error(chalk.yellow(IMPORT_DEPRECATION)); + await runImport(source, { path: options.path, scan: options.scan, dryRun: options.dryRun }); + return; + } + await runCommand('publish', options.json, () => runReleasePublish(source ?? '.', options), renderPublished); +}); diff --git a/packages/cli/src/commands/release-commands.test.ts b/packages/cli/src/commands/release-commands.test.ts new file mode 100644 index 0000000..5ef3a31 --- /dev/null +++ b/packages/cli/src/commands/release-commands.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { publishMode } from './publish.js'; +import { parseReleaseTarget, runPromote } from './promote.js'; +import { CliError, publishCredential } from '../lib/release-cli.js'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('publish mode', () => { + it('treats GitHub repositories as the deprecated import and everything else as a release', () => { + expect(publishMode('nextlevelbuilder/skillx', {})).toBe('import'); + expect(publishMode('https://github.com/nextlevelbuilder/skillx', {})).toBe('import'); + expect(publishMode(undefined, {})).toBe('import'); + expect(publishMode('.', {})).toBe('release'); + expect(publishMode('src', {})).toBe('release'); + expect(publishMode('HEAD', {})).toBe('release'); + expect(publishMode('owner/repo', { name: '@a/b' })).toBe('release'); + }); +}); + +describe('promote', () => { + it('parses @publisher/slug@version', () => { + expect(parseReleaseTarget('@alpha/demo@1.2.0-beta.1')).toMatchObject({ handle: 'alpha', slug: 'demo', version: '1.2.0-beta.1' }); + expect(() => parseReleaseTarget('@alpha/demo')).toThrow(CliError); + expect(() => parseReleaseTarget('alpha/demo@1.0.0')).toThrow(CliError); + }); + + it('posts the move with the API key and maps registry failures onto envelope codes', async () => { + vi.stubEnv('SKILLX_API_KEY', 'sx_key'); + const fetchMock = vi.fn(async () => json({ from: 'beta', to: 'stable' })); + vi.stubGlobal('fetch', fetchMock); + expect(await runPromote('@alpha/demo@1.0.0', { to: 'stable', from: 'beta', registry: 'https://reg.test' })).toEqual({ from: 'beta', to: 'stable' }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://reg.test/api/v1/packages/@alpha/demo/releases/1.0.0/promote'); + expect(JSON.parse(String(init.body))).toEqual({ to: 'stable', from: 'beta' }); + expect((init.headers as Record).Authorization).toBe('Bearer sx_key'); + + fetchMock.mockResolvedValueOnce(json({ error: { code: 'promote.demotion_forbidden', message: 'no' } }, 403)); + await expect(runPromote('@alpha/demo@1.0.0', { to: 'beta', registry: 'https://reg.test' })).rejects.toMatchObject({ failure: { error: { code: 'auth_error' } } }); + await expect(runPromote('@alpha/demo@1.0.0', { to: 'nightly', registry: 'https://reg.test' })).rejects.toMatchObject({ failure: { error: { code: 'usage_error' } } }); + }); +}); + +describe('OIDC credential', () => { + it('exchanges the GitHub Actions token for a publish token', async () => { + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', 'https://gh.test/token?x=1'); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'runner-bearer'); + const fetchMock = vi.fn(async (url: string) => (url.startsWith('https://gh.test') ? json({ value: 'id-token' }) : json({ token: 'sxo_short' }))); + vi.stubGlobal('fetch', fetchMock); + expect(await publishCredential('https://reg.test', 'bestagentkits', { oidc: true })).toBe('sxo_short'); + expect(fetchMock.mock.calls[0][0]).toBe('https://gh.test/token?x=1&audience=skillx'); + const [, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ token: 'id-token', publisher: '@bestagentkits' }); + }); + + it('fails as auth_error outside GitHub Actions', async () => { + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', ''); + await expect(publishCredential('https://reg.test', 'a', { oidc: true })).rejects.toMatchObject({ failure: { error: { code: 'auth_error' } } }); + }); +}); diff --git a/packages/cli/src/commands/release-pack.ts b/packages/cli/src/commands/release-pack.ts index 0f384b2..a8acec0 100644 --- a/packages/cli/src/commands/release-pack.ts +++ b/packages/cli/src/commands/release-pack.ts @@ -65,27 +65,3 @@ export function packSummary(packed: PackedRelease) { }; } -export const releasePackCommand = new Command('pack') - .description('Pack a skill from git at an exact commit into a deterministic release tar') - .argument('', 'package directory (packs HEAD) or git ref (with --path)') - .requiredOption('-o, --out ', 'output tar file') - .option('-p, --path ', 'package directory inside the repository, for a git ref') - .option('--json', 'print the result as JSON') - .action(async (input: string, options: { out: string; path?: string; json?: boolean }) => { - try { - const packed = await packRelease(input, { packagePath: options.path }); - warnPackNotes(packed); - writeFileSync(options.out, packed.tar); - const summary = { ...packSummary(packed), out: options.out }; - if (options.json) { - console.log(JSON.stringify(summary, null, 2)); - return; - } - console.log(summary.releaseDigest); - console.error(chalk.dim(`commit ${summary.commit}${summary.path ? ` path ${summary.path}` : ''}`)); - console.error(chalk.dim(`${summary.fileCount} files, ${summary.treeSize} bytes -> ${summary.out} (${summary.artifactDigest})`)); - } catch (error) { - console.error(chalk.red(`release pack failed: ${error instanceof Error ? error.message : String(error)}`)); - process.exit(1); - } - }); diff --git a/packages/cli/src/commands/release-publish.ts b/packages/cli/src/commands/release-publish.ts index e1795e2..be20c8e 100644 --- a/packages/cli/src/commands/release-publish.ts +++ b/packages/cli/src/commands/release-publish.ts @@ -1,10 +1,10 @@ import { Command } from 'commander'; import { spawnSync } from 'node:child_process'; import chalk from 'chalk'; -import { getApiKey, getBaseUrl } from '../utils/config-store.js'; -import { packRelease, packSummary, releasePackCommand, warnPackNotes } from './release-pack.js'; - -const NAME_PATTERN = /^@([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)$/; +import { getBaseUrl } from '../utils/config-store.js'; +import { CliError, parsePackageName, publishCredential, registryFetch, runCommand, usageError } from '../lib/release-cli.js'; +import { createPackCommand, packOrFail } from './pack.js'; +import { packSummary } from './release-pack.js'; /** HTTPS form of a git remote without credentials, or null when it has none. */ export function httpsRepoUrl(remote: string): string | null { @@ -53,92 +53,102 @@ function originUrl(repoRoot: string): string | null { return result.status === 0 ? httpsRepoUrl(result.stdout) : null; } -interface PublishOptions { - name: string; - releaseVersion: string; +export interface ReleasePublishOptions { + name?: string; + releaseVersion?: string; path?: string; channel: string; kind: string; repo?: string; script: string[]; registry?: string; + idempotencyKey?: string; + oidc?: boolean; + oidcAudience?: string; json?: boolean; } const collect = (value: string, previous: string[]) => [...previous, value]; -export const releasePublishCommand = new Command('publish') - .description('Pack a skill from git and upload it as an immutable SkillX release (needs a releases:publish API key)') - .argument('', 'package directory (packs HEAD) or git ref (with --path)') - .requiredOption('-n, --name <@publisher/slug>', 'package name') - // Not --version: the root program owns that flag in every position. - .requiredOption('--release-version ', 'release version, e.g. 1.0.0') - .option('-p, --path ', 'package directory inside the repository, for a git ref') - .option('--channel ', 'stable | beta | alpha', 'stable') - .option('--kind ', 'skill | hook-pack | bundle', 'skill') - .option('--repo ', 'source repository URL for provenance (default: git remote origin)') - .option('--script ', 'declare a packaged file as a script (repeatable)', collect, []) - .option('--registry ', 'registry base URL (default: configured base URL)') - .option('--json', 'print the result as JSON') - .action(async (input: string, options: PublishOptions) => { - try { - const name = NAME_PATTERN.exec(options.name); - if (!name) throw new Error(`--name must be @publisher/slug, got ${JSON.stringify(options.name)}`); - const apiKey = getApiKey(); - if (!apiKey) throw new Error('an API key is required: set SKILLX_API_KEY or run skillx config set api-key '); - if (options.repo !== undefined && httpsRepoUrl(options.repo) !== options.repo) { - throw new Error('--repo must be an https URL without credentials or a trailing .git'); - } +/** Registry base URL from --registry or the configured base URL; a bad one is a usage error. */ +export function resolveRegistry(raw: string | undefined): string { + try { + return registryBaseUrl(raw ?? getBaseUrl()); + } catch (error) { + throw usageError((error as Error).message); + } +} + +/** Packs `input` and uploads it as release `--name`@`--release-version`; the registry must store the same tree. */ +export async function runReleasePublish(input: string, options: ReleasePublishOptions) { + const name = parsePackageName(options.name); + if (!options.releaseVersion) throw usageError('--release-version is required, e.g. 1.0.0'); + if (options.repo !== undefined && httpsRepoUrl(options.repo) !== options.repo) { + throw usageError('--repo must be an https URL without credentials or a trailing .git'); + } + const base = resolveRegistry(options.registry); + const packed = await packOrFail(input, options.path); + const credential = await publishCredential(base, name.handle, options); + const repo = options.repo ?? originUrl(packed.source.repoRoot); + if (repo === null) console.error(chalk.yellow('warning: no https origin remote; the release will record no source provenance')); + + const metadata = { + version: options.releaseVersion, + channel: options.channel, + kind: options.kind, + sourceProvenance: repo === null ? null : { repo, commit: packed.commit, path: packed.path }, + declaredScripts: [...options.script].sort(), + }; + const headers: Record = { + Authorization: `Bearer ${credential}`, + 'Content-Type': 'application/x-tar', + 'X-SkillX-Release': Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64'), + }; + if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey; + const body = await registryFetch(`${base}/api/v1/packages/@${name.handle}/${name.slug}/releases`, { method: 'POST', headers, body: packed.tar }); + try { + assertRegistryDigests(packed, body); + } catch (error) { + throw new CliError('api_error', (error as Error).message, { details: { local: packSummary(packed) } }); + } + return { ...packSummary(packed), ...body, name: name.name } as ReturnType & Record & { name: string }; +} - const base = registryBaseUrl(options.registry ?? getBaseUrl()); - const packed = await packRelease(input, { packagePath: options.path }); - warnPackNotes(packed); - const repo = options.repo ?? originUrl(packed.source.repoRoot); - if (repo === null) console.error(chalk.yellow('warning: no https origin remote; the release will record no source provenance')); +export function renderPublished(result: Awaited>): void { + console.log(chalk.bold.green(`${result.created ? 'Published' : 'Already published'} ${result.name}@${String(result.version)}`)); + console.log(` releaseDigest: ${chalk.cyan(String(result.releaseDigest))}`); + console.log(` state: ${result.state === 'published' ? chalk.green('published') : chalk.yellow(String(result.state))}`); +} - const metadata = { - version: options.releaseVersion, - channel: options.channel, - kind: options.kind, - sourceProvenance: repo === null ? null : { repo, commit: packed.commit, path: packed.path }, - declaredScripts: [...options.script].sort(), - }; - const res = await fetch(`${base}/api/v1/packages/@${name[1]}/${name[2]}/releases`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/x-tar', - 'X-SkillX-Release': Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64'), - }, - body: packed.tar, - }); - const text = await res.text(); - let body: Record = {}; - try { - body = JSON.parse(text) as Record; - } catch { - // Non-JSON answers are reported below with their status. - } - if (!res.ok) { - const error = body.error as { code?: string; message?: string } | undefined; - throw new Error(`registry answered ${res.status}${error?.code ? ` ${error.code}` : ''}: ${error?.message ?? text.slice(0, 300)}`); - } - assertRegistryDigests(packed, body); - const result = { ...packSummary(packed), ...body }; - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - console.log(chalk.bold.green(`${body.created ? 'Published' : 'Already published'} ${options.name}@${options.releaseVersion}`)); - console.log(` releaseDigest: ${chalk.cyan(String(body.releaseDigest))}`); - console.log(` state: ${body.state === 'published' ? chalk.green('published') : chalk.yellow(String(body.state))}`); - } catch (error) { - console.error(chalk.red(`release publish failed: ${error instanceof Error ? error.message : String(error)}`)); - process.exit(1); - } +/** The release publish options, shared by `skillx publish` and `skillx release publish`. */ +export function withReleasePublishOptions(command: Command): Command { + return command + .option('-n, --name <@publisher/slug>', 'package name (release mode)') + // Not --version: the root program owns that flag in every position. + .option('--release-version ', 'release version, e.g. 1.0.0 (release mode)') + .option('-p, --path ', 'package directory inside the repository, for a git ref') + .option('--channel ', 'stable | beta | alpha', 'stable') + .option('--kind ', 'skill | hook-pack | bundle', 'skill') + .option('--repo ', 'source repository URL for provenance (default: git remote origin)') + .option('--script ', 'declare a packaged file as a script (repeatable)', collect, []) + .option('--registry ', 'registry base URL (default: configured base URL)') + .option('--idempotency-key ', 'Idempotency-Key header: a retry with the same key replays the first answer') + .option('--oidc', 'authenticate with the GitHub Actions OIDC token (trusted publishing) instead of an API key') + .option('--oidc-audience ', 'audience requested for the OIDC token', 'skillx') + .option('--json', 'print the result as a JSON envelope'); +} + +export function createReleasePublishCommand(): Command { + return withReleasePublishOptions( + new Command('publish') + .description('Pack a skill from git and upload it as an immutable SkillX release (alias of `skillx publish `)') + .argument('', 'package directory (packs HEAD) or git ref (with --path)'), + ).action(async (input: string, options: ReleasePublishOptions) => { + await runCommand('release publish', options.json, () => runReleasePublish(input, options), renderPublished); }); +} export const releaseCommand = new Command('release') - .description('Build and publish immutable, digest-pinned releases') - .addCommand(releasePackCommand) - .addCommand(releasePublishCommand); + .description('Aliases of `skillx pack` and `skillx publish `') + .addCommand(createPackCommand()) + .addCommand(createReleasePublishCommand()); \ No newline at end of file diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..419809b --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,91 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { parse as parseYaml } from 'yaml'; +import { CliError, parsePackageName, runCommand } from '../lib/release-cli.js'; +import { packOrFail } from './pack.js'; +import { packSummary } from './release-pack.js'; +import type { PackedRelease } from './release-pack.js'; + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const KINDS = ['skill', 'hook-pack', 'bundle']; +const CHANNELS = ['stable', 'beta', 'alpha']; + +interface ValidateOptions { + path?: string; + name?: string; + releaseVersion?: string; + kind: string; + channel: string; + json?: boolean; +} + +/** The frontmatter of the root SKILL.md, or the problems that stop it. */ +function readSkill(packed: PackedRelease, problems: string[]): { name: string; description: string } | null { + const skill = packed.source.files.find((f) => f.path === 'SKILL.md'); + if (!skill) { + problems.push('SKILL.md is missing at the package root'); + return null; + } + const text = new TextDecoder('utf-8', { fatal: false }).decode(skill.bytes); + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text); + if (!match) { + problems.push('SKILL.md has no YAML frontmatter (--- ... ---)'); + return null; + } + let data: unknown; + try { + data = parseYaml(match[1]); + } catch (error) { + problems.push(`SKILL.md frontmatter is not valid YAML: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + const record = (typeof data === 'object' && data !== null ? data : {}) as Record; + for (const field of ['name', 'description']) { + if (typeof record[field] !== 'string' || (record[field] as string).trim() === '') problems.push(`SKILL.md frontmatter needs a non-empty ${field}`); + } + return problems.length > 0 ? null : { name: record.name as string, description: record.description as string }; +} + +/** + * Checks a package the way publishing would, without uploading: packs it with + * the registry's tree reader (symlinks, submodules, unsafe paths fail here), + * requires a root SKILL.md with name and description for a skill, and checks + * the optional --name, --release-version, --kind and --channel. Exit 0 when + * valid, 2 with every problem listed when not. + */ +export async function validatePackage(input: string, options: ValidateOptions) { + const problems: string[] = []; + if (options.name !== undefined) { + try { + parsePackageName(options.name); + } catch (error) { + problems.push((error as Error).message); + } + } + if (options.releaseVersion !== undefined && !SEMVER.test(options.releaseVersion)) problems.push(`--release-version must be SemVer without build metadata, got ${options.releaseVersion}`); + if (!KINDS.includes(options.kind)) problems.push(`--kind must be one of: ${KINDS.join(', ')}`); + if (!CHANNELS.includes(options.channel)) problems.push(`--channel must be one of: ${CHANNELS.join(', ')}`); + const packed = await packOrFail(input, options.path); + const skill = options.kind === 'skill' ? readSkill(packed, problems) : null; + if (problems.length > 0) { + throw new CliError('usage_error', `${problems.length} problem(s): ${problems.join('; ')}`, { details: { problems } }); + } + return { valid: true as const, ...packSummary(packed), kind: options.kind, skill, uncommitted: packed.source.uncommitted.length }; +} + +export const validateCommand = new Command('validate') + .description('Check that a package can be published (same packing and tree rules as the registry), without uploading') + .argument('[source]', 'package directory (packs HEAD) or git ref (with --path)', '.') + .option('-p, --path ', 'package directory inside the repository, for a git ref') + .option('-n, --name <@publisher/slug>', 'also check the package name') + .option('--release-version ', 'also check the release version') + .option('--kind ', 'skill | hook-pack | bundle', 'skill') + .option('--channel ', 'stable | beta | alpha', 'stable') + .option('--json', 'print the result as a JSON envelope') + .action(async (input: string, options: ValidateOptions) => { + await runCommand('validate', options.json, () => validatePackage(input, options), (result) => { + console.log(chalk.green(`valid ${result.skill ? `skill ${result.skill.name}` : result.kind}`)); + console.log(` releaseDigest: ${chalk.cyan(result.releaseDigest)}`); + console.log(chalk.dim(` ${result.fileCount} files, ${result.treeSize} bytes at commit ${result.commit}`)); + }); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e3f9d35..f01cdc9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,10 @@ import { checkCommand } from './commands/check.js'; import { reportCommand } from './commands/report.js'; import { configCommand } from './commands/config.js'; import { publishCommand } from './commands/publish.js'; +import { importCommand } from './commands/import.js'; +import { validateCommand } from './commands/validate.js'; +import { createPackCommand } from './commands/pack.js'; +import { promoteCommand } from './commands/promote.js'; import { releaseCommand } from './commands/release-publish.js'; /** Injected at build time from packages/cli/package.json by tsup (see tsup.config.ts). */ @@ -27,7 +31,11 @@ program.addCommand(inspectCommand); program.addCommand(checkCommand); program.addCommand(reportCommand); program.addCommand(configCommand); +program.addCommand(importCommand); +program.addCommand(validateCommand); +program.addCommand(createPackCommand()); program.addCommand(publishCommand); +program.addCommand(promoteCommand); program.addCommand(releaseCommand); program.parse(process.argv); diff --git a/packages/cli/src/lib/release-cli.ts b/packages/cli/src/lib/release-cli.ts new file mode 100644 index 0000000..8c6a460 --- /dev/null +++ b/packages/cli/src/lib/release-cli.ts @@ -0,0 +1,114 @@ +/** + * Shared plumbing of the release commands (`validate`, `pack`, `publish`, + * `promote`): the JSON envelope with standard exit codes, registry requests + * whose failures map onto that vocabulary, and the publish credential (an API + * key, or a GitHub Actions OIDC token exchanged for a short-lived one). + */ +import chalk from 'chalk'; +import { exitCodeFor, failure, fromError, serialize, success } from './output.js'; +import type { CliErrorCode, CliFailure } from './output.js'; +import { getApiKey } from '../utils/config-store.js'; + +/** A failure that already knows its envelope code. */ +export class CliError extends Error { + readonly failure: CliFailure; + + constructor(code: CliErrorCode, message: string, extra: { details?: unknown; hint?: string } = {}) { + super(message); + this.failure = failure(code, message, extra); + } +} + +export const usageError = (message: string) => new CliError('usage_error', message); + +/** + * Runs one command. With `--json` stdout carries exactly one envelope, success + * or failure; without it `render` prints for humans and errors go to stderr. + * The exit code follows the envelope either way. + */ +export async function runCommand(label: string, json: boolean | undefined, work: () => Promise, render: (data: T) => void): Promise { + try { + const data = await work(); + if (json) console.log(serialize(success(data))); + else render(data); + } catch (error) { + const envelope = error instanceof CliError ? error.failure : fromError(error); + if (json) console.log(serialize(envelope)); + else console.error(chalk.red(`${label} failed: ${envelope.error.message}`)); + process.exitCode = exitCodeFor(envelope); + } +} + +const NAME_PATTERN = /^@([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)$/; + +export function parsePackageName(raw: string | undefined): { handle: string; slug: string; name: string } { + const match = NAME_PATTERN.exec(raw ?? ''); + if (!match) throw usageError(`--name must be @publisher/slug, got ${JSON.stringify(raw ?? '')}`); + return { handle: match[1], slug: match[2], name: raw! }; +} + +/** Sends one registry request and returns its JSON body, or throws the matching CliError. */ +export async function registryFetch(url: string, init: RequestInit): Promise> { + let res: Response; + try { + res = await fetch(url, init); + } catch (error) { + throw new CliError('network_error', `cannot reach ${new URL(url).origin}: ${error instanceof Error ? error.message : String(error)}`, { + hint: 'Check the network connection and --registry.', + }); + } + const text = await res.text(); + let body: Record = {}; + try { + body = JSON.parse(text) as Record; + } catch { + // Non-JSON answers are reported with their status. + } + if (res.ok) return body; + const error = body.error as { code?: string; message?: string } | undefined; + const message = `registry answered ${res.status}${error?.code ? ` ${error.code}` : ''}: ${error?.message ?? text.slice(0, 300)}`; + const details = { status: res.status, code: error?.code ?? null }; + if (res.status === 401 || res.status === 403) throw new CliError('auth_error', message, { details }); + if (res.status === 404) throw new CliError('not_found', message, { details }); + throw new CliError('api_error', message, { details }); +} + +/** Requests a GitHub Actions OIDC token for `audience` (needs `permissions: id-token: write`). */ +async function githubOidcToken(audience: string): Promise { + const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const bearer = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!url || !bearer) { + throw new CliError('auth_error', '--oidc needs a GitHub Actions job with `permissions: id-token: write`', { + hint: 'Outside GitHub Actions, use an API key (SKILLX_API_KEY).', + }); + } + const body = await registryFetch(`${url}${url.includes('?') ? '&' : '?'}audience=${encodeURIComponent(audience)}`, { + headers: { Authorization: `bearer ${bearer}`, Accept: 'application/json' }, + }); + if (typeof body.value !== 'string') throw new CliError('auth_error', 'GitHub returned no OIDC token'); + return body.value; +} + +export interface CredentialOptions { + oidc?: boolean; + oidcAudience?: string; +} + +/** The bearer for publisher writes of `@handle`: an exchanged OIDC token with `--oidc`, else the API key. */ +export async function publishCredential(base: string, handle: string, options: CredentialOptions): Promise { + if (options.oidc) { + const idToken = await githubOidcToken(options.oidcAudience ?? 'skillx'); + const body = await registryFetch(`${base}/api/v1/oidc/github/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: idToken, publisher: `@${handle}` }), + }); + if (typeof body.token !== 'string') throw new CliError('auth_error', 'the registry returned no publish token'); + return body.token; + } + const apiKey = getApiKey(); + if (!apiKey) { + throw new CliError('auth_error', 'an API key is required', { hint: 'Set SKILLX_API_KEY, run `skillx config set api-key `, or use --oidc in GitHub Actions.' }); + } + return apiKey; +} From 92ac49e62c22c26de6a62a024d146ebf51402db3 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:58:06 +0700 Subject: [PATCH 03/11] ci: add AgentKit foundation publish workflow and read the CLI envelope Co-Authored-By: Claude Opus 5.5 --- .../workflows/publish-agentkit-foundation.yml | 184 ++++++++++++++++++ .github/workflows/publish-release.yml | 7 +- 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-agentkit-foundation.yml diff --git a/.github/workflows/publish-agentkit-foundation.yml b/.github/workflows/publish-agentkit-foundation.yml new file mode 100644 index 0000000..b520446 --- /dev/null +++ b/.github/workflows/publish-agentkit-foundation.yml @@ -0,0 +1,184 @@ +# Publishes one AgentKit foundation skill as an immutable SkillX release, from +# bestagentkits/agentkit at a pinned commit, with the skillx CLI built from +# this repository at the dispatching commit. +# +# Run it by hand only (workflow_dispatch from main). It packs with +# `skillx pack` (the same deterministic packer a maintainer runs locally), so +# the printed releaseDigest can be compared with a manual publish of the same +# commit; pass `expected-digest` to make a mismatch fail the job before any +# upload. +# +# Authentication, in order: +# 1. GitHub OIDC trusted publishing (`skillx publish --oidc`): the registry +# must hold a trusted publisher of @bestagentkits for repository +# nextlevelbuilder/skillx (with its numeric repository id), workflow +# .github/workflows/publish-agentkit-foundation.yml, ref refs/heads/main +# and environment skillx-publish (see docs/deployment-guide.md). +# 2. Fallback: SKILLX_AGENTKIT_PUBLISH_KEY, an API key scoped to +# releases:publish:@bestagentkits, stored as a skillx-publish environment +# secret. Used only when the OIDC exchange is refused (exit code 5). +# Both attempts send the same Idempotency-Key, so a retry never publishes twice. +# +# AGENTKIT_READ_TOKEN (optional, read-only contents access to +# bestagentkits/agentkit) is needed only while that repository is private. +name: Publish AgentKit foundation skill + +on: + workflow_dispatch: + inputs: + agentkit-sha: + description: "Full 40-character commit SHA of bestagentkits/agentkit to publish" + required: true + type: string + skill-path: + description: "Skill directory inside the agentkit repository, e.g. skills/foo" + required: true + type: string + version: + description: "SemVer release version (no build metadata)" + required: true + type: string + channel: + description: "Release channel" + required: false + default: beta + type: choice + options: [stable, beta, alpha] + name: + description: "Package identity; default @bestagentkits/" + required: false + type: string + expected-digest: + description: "Optional releaseDigest of a manual publish; the job fails before upload when the pack differs" + required: false + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: skillx-agentkit-foundation-${{ inputs.skill-path }}-${{ inputs.version }} + cancel-in-progress: false + +jobs: + publish: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: skillx-publish + steps: + - name: Validate inputs + id: inputs + env: + SHA: ${{ inputs.agentkit-sha }} + SKILL_PATH: ${{ inputs.skill-path }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + NAME_INPUT: ${{ inputs.name }} + EXPECTED: ${{ inputs.expected-digest }} + run: | + set -euo pipefail + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::agentkit-sha must be a full lowercase commit SHA"; exit 1; } + [[ "$SKILL_PATH" =~ ^[A-Za-z0-9._/-]+$ && "$SKILL_PATH" != -* && "$SKILL_PATH" != /* && "$SKILL_PATH" != *..* ]] || { echo "::error::invalid skill-path"; exit 1; } + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::invalid version"; exit 1; } + [[ "$CHANNEL" =~ ^(stable|beta|alpha)$ ]] || { echo "::error::invalid channel"; exit 1; } + name="${NAME_INPUT:-@bestagentkits/$(basename "$SKILL_PATH")}" + [[ "$name" =~ ^@bestagentkits/[a-z0-9][a-z0-9._-]*$ ]] || { echo "::error::name must be @bestagentkits/ (lowercase)"; exit 1; } + [[ -z "$EXPECTED" || "$EXPECTED" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::expected-digest must be sha256:<64 hex>"; exit 1; } + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "idempotency-key=agentkit-foundation:$SHA:$name@$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + path: skillx + persist-credentials: false + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: bestagentkits/agentkit + ref: ${{ inputs.agentkit-sha }} + path: agentkit + fetch-depth: 1 + persist-credentials: false + token: ${{ secrets.AGENTKIT_READ_TOKEN || github.token }} + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + package_json_file: skillx/package.json + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: skillx/pnpm-lock.yaml + + - name: Build the skillx CLI + working-directory: skillx + run: | + pnpm install --frozen-lockfile + pnpm --filter skillx-sh build + + - name: Validate and pack + id: pack + working-directory: agentkit + env: + SHA: ${{ inputs.agentkit-sha }} + SKILL_PATH: ${{ inputs.skill-path }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + NAME: ${{ steps.inputs.outputs.name }} + EXPECTED: ${{ inputs.expected-digest }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$SHA" ]] || { echo "::error::checked out $(git rev-parse HEAD), expected $SHA"; exit 1; } + cli="$GITHUB_WORKSPACE/skillx/packages/cli/bin/skillx.js" + node "$cli" validate HEAD --path "$SKILL_PATH" --name "$NAME" --release-version "$VERSION" --channel "$CHANNEL" --json + node "$cli" pack HEAD --path "$SKILL_PATH" --out "$RUNNER_TEMP/release.tar" --json > "$RUNNER_TEMP/pack.json" + digest="$(node -e 'const r=require(process.argv[1]).data; if(!/^sha256:[0-9a-f]{64}$/.test(r.releaseDigest)) process.exit(1); process.stdout.write(r.releaseDigest)' "$RUNNER_TEMP/pack.json")" + echo "Packed $NAME@$VERSION at $SHA: releaseDigest $digest" + if [[ -n "$EXPECTED" && "$digest" != "$EXPECTED" ]]; then + echo "::error::releaseDigest $digest differs from expected-digest $EXPECTED; nothing was uploaded" + exit 1 + fi + echo "release-digest=$digest" >> "$GITHUB_OUTPUT" + + - name: Publish (OIDC, fallback API key) + working-directory: agentkit + env: + SKILLX_AGENTKIT_PUBLISH_KEY: ${{ secrets.SKILLX_AGENTKIT_PUBLISH_KEY }} + SKILL_PATH: ${{ inputs.skill-path }} + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + NAME: ${{ steps.inputs.outputs.name }} + IDEMPOTENCY_KEY: ${{ steps.inputs.outputs.idempotency-key }} + PACK_DIGEST: ${{ steps.pack.outputs.release-digest }} + run: | + set -euo pipefail + cli="$GITHUB_WORKSPACE/skillx/packages/cli/bin/skillx.js" + publish() { + node "$cli" publish HEAD --path "$SKILL_PATH" --name "$NAME" --release-version "$VERSION" \ + --channel "$CHANNEL" --repo https://github.com/bestagentkits/agentkit \ + --registry https://skillx.sh --idempotency-key "$IDEMPOTENCY_KEY" --json "$@" + } + status=0 + SKILLX_API_KEY= publish --oidc > "$RUNNER_TEMP/release.json" || status=$? + if [[ $status -eq 5 && -n "$SKILLX_AGENTKIT_PUBLISH_KEY" ]]; then + echo "::warning::OIDC trusted publishing was refused; falling back to SKILLX_AGENTKIT_PUBLISH_KEY" + status=0 + SKILLX_API_KEY="$SKILLX_AGENTKIT_PUBLISH_KEY" publish > "$RUNNER_TEMP/release.json" || status=$? + fi + if [[ $status -ne 0 ]]; then + node -e 'const j=require(process.argv[1]); console.log(`::error::${j.error?.code}: ${j.error?.message}`)' "$RUNNER_TEMP/release.json" || true + exit "$status" + fi + digest="$(node -e 'process.stdout.write(String(require(process.argv[1]).data.releaseDigest))' "$RUNNER_TEMP/release.json")" + state="$(node -e 'process.stdout.write(String(require(process.argv[1]).data.state))' "$RUNNER_TEMP/release.json")" + [[ "$digest" == "$PACK_DIGEST" ]] || { echo "::error::registry releaseDigest $digest differs from the pack $PACK_DIGEST"; exit 1; } + echo "Published $NAME@$VERSION releaseDigest $digest (state $state)" + { + echo "### $NAME@$VERSION" + echo "- agentkit commit: \`$(git rev-parse HEAD)\`" + echo "- releaseDigest: \`$digest\` (compare with the manual publish of the same commit)" + echo "- state: $state, channel: $CHANNEL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 43d1a7a..e92d0b0 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -13,7 +13,8 @@ # environment and optionally require reviewers. An environment secret takes # precedence over one passed by a caller. The key only goes to the production # registry (https://skillx.sh); only workflow_call may name another https host. -# Needs a published skillx-sh version that has the `release` command. +# Needs a published skillx-sh version that has the `release` command. Its `--json` +# output is read both as the 0.x raw object and as the `{ok, data}` envelope. # # Caller example: # jobs: @@ -141,6 +142,6 @@ jobs: --repo "$REPO_URL" \ --registry "$REGISTRY" \ --json > "$RUNNER_TEMP/release.json" - digest="$(node -e 'const r=require(process.argv[1]); if(!/^sha256:[0-9a-f]{64}$/.test(r.releaseDigest)) process.exit(1); process.stdout.write(r.releaseDigest)' "$RUNNER_TEMP/release.json")" + digest="$(node -e 'const j=require(process.argv[1]); const r=j.data ?? j; if(!/^sha256:[0-9a-f]{64}$/.test(r.releaseDigest)) process.exit(1); process.stdout.write(r.releaseDigest)' "$RUNNER_TEMP/release.json")" echo "release-digest=$digest" >> "$GITHUB_OUTPUT" - echo "Published $NAME@$VERSION ($digest), state: $(node -e 'process.stdout.write(String(require(process.argv[1]).state))' "$RUNNER_TEMP/release.json")" >> "$GITHUB_STEP_SUMMARY" + echo "Published $NAME@$VERSION ($digest), state: $(node -e 'const j=require(process.argv[1]); process.stdout.write(String((j.data ?? j).state))' "$RUNNER_TEMP/release.json")" >> "$GITHUB_STEP_SUMMARY" From ea2584aefbbcecb062f22b603414de54a171a21f Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 19:58:07 +0700 Subject: [PATCH 04/11] docs: document registry publishing, OIDC trusted publishing and release resolution policy Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +- docs/api-reference.md | 182 +++++++++++++++++++++++++++++- docs/cli-reference.md | 111 +++++++++++++----- docs/deployment-guide.md | 40 +++++++ docs/release-resolution-policy.md | 64 +++++++++++ 5 files changed, 373 insertions(+), 31 deletions(-) create mode 100644 docs/release-resolution-policy.md diff --git a/CLAUDE.md b/CLAUDE.md index ddcd069..286f361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,11 @@ pnpm db:migrate:remote # Apply migrations to remote D1 | `/api/v1/packages/:scope/:slug/releases/:version/state`, `/api/v1/publishers/:handle/state` | api.v1.release-state.ts (POST compare-and-set state change, audited) | Admin `X-Admin-Secret` or owner session/full API key; scoped keys refused (404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/publishers` | api.v1.publishers.ts (POST provision an active publisher; idempotent, audited) | Admin `X-Admin-Secret` only; accounts 403 (404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/keys` | api.v1.signing-keys.ts | None (404 unless `SKILLX_RELEASES_API` on) | +| `/api/v1/packages/:scope/:slug/releases/:version/promote` | api.v1.release-promote.ts (POST channel move, audited; publish credentials may only promote) | Scoped key / OIDC publish token, owner session/key, or admin (404 unless `SKILLX_RELEASES_API` on) | +| `/api/v1/packages/:scope/:slug/releases/:version/review`, `/api/v1/admin/quarantine` | api.v1.release-review.ts, api.v1.admin-quarantine.ts (approve/reject quarantined releases; see docs/release-resolution-policy.md) | Admin `X-Admin-Secret` only (404 unless `SKILLX_RELEASES_API` on) | +| `/api/v1/publishers/:handle/trusted-publishers`, `.../:id/revoke` | api.v1.trusted-publishers.ts (GitHub OIDC trust config, audited) | Admin `X-Admin-Secret` only (404 unless `SKILLX_RELEASES_API` on) | +| `/api/v1/oidc/github/token` | api.v1.oidc-github-token.ts (GitHub Actions JWT → 15 min `sxo_` token scoped `releases:publish:@handle`; jti replay-checked) | GitHub OIDC JWT (503 without `SKILLX_TOKEN_SECRET`; 404 unless `SKILLX_RELEASES_API` on) | +| `/api/v1/releases/:digest/artifact/url`, `/api/v1/downloads/:token` | api.v1.release-download-url.ts, api.v1.release-download.ts (5 min signed artifact URL for private/entitled releases) | URL: entitled Key/Session; download: the token (404 unless `SKILLX_RELEASES_API` on) | | `/api/v1/kits/:scope/:name`, `/api/v1/kits/:scope/:name/*` | api.v1.kits.ts (personal kit revisions, rebase, conflicts, session-derived proposals and review, activate/rollback, lock; see docs/personal-kits-api.md) | Owner session or unscoped API key; scoped keys 401, non-owners 404; `revisions/:id/quarantine` admin secret only (404 unless `SKILLX_PERSONAL_KITS_API` on) | ## Database Tables (Drizzle schema) @@ -138,7 +143,7 @@ Search algorithm: `./docs/search-algorithm.md` **CLI `skillx use` resolution**: `author/skill` (two-part → DB slug `author-skill`) | `org/repo/skill` (three-part → DB slug `org-skill`, fallback register from GitHub) | `slug` (direct lookup, fallback search) | `"keywords"` (search mode). Flags: `--include-refs`, `--include-scripts` for extended content. Display logic split into `use-display.ts` module. -**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx publish owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth. +**Register & Publish APIs**: POST `/api/skills/register` with `{ owner, repo, skill_path?, scan? }`. GitHub ownership verified via collaborator check. Content scanned for security (risk_label: safe/caution/danger/unknown). CLI `skillx import owner/repo [--path X] [--scan] [--dry-run]` — requires API key auth (`skillx publish owner/repo` is a deprecated alias). Releases: `skillx validate|pack|publish |promote` (`skillx release pack|publish` are aliases), JSON envelope via `--json`. **Styling**: Always dark theme. Use `bg-slate-900`, `text-white`, `text-mint`, `border-mint/20`. Geist Sans/Mono fonts. Lucide icons. diff --git a/docs/api-reference.md b/docs/api-reference.md index 64c41b7..b25eb5b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1074,14 +1074,14 @@ index: one row wins and the loser gets the same idempotent `200` or `409`. | Status | Codes | |--------|-------| -| 400 | `identity.*` (invalid names), `release.invalid_metadata`, `tree.read_failed` (empty body) | -| 401 | `auth.invalid_key` | +| 400 | `identity.*` (invalid names), `release.invalid_metadata`, `tree.read_failed` (empty body), `idempotency.invalid_key` | +| 401 | `auth.invalid_key` (also an expired, forged or revoked `sxo_` OIDC publish token) | | 403 | `identity.reserved_publisher` (`@agentkit`), `identity.reserved_alias` (`ak-`/`ak:` slugs), `auth.scope_missing`, `publisher.not_owner`, `publisher.inactive` | | 404 | `not_found` (API disabled) | -| 409 | `release.version_exists`, `release.kind_conflict` | +| 409 | `release.version_exists`, `release.kind_conflict`, `idempotency.in_progress` | | 413 | `tree.limit_exceeded` | | 415 | `release.unsupported_media_type` | -| 422 | other `tree.*` codes from the skill-tree reader, `exec.manifest_invalid`, `exec.entrypoint_undeclared` | +| 422 | other `tree.*` codes from the skill-tree reader, `exec.manifest_invalid`, `exec.entrypoint_undeclared`, `idempotency.key_reused` | | 500 | `release.internal_error` | | 503 | `release.signing_unavailable` | @@ -1091,6 +1091,64 @@ index: one row wins and the loser gets the same idempotent `200` or `409`. `skillx-reg-993cd14a011f7b76`). A missing or malformed key fails closed with `503`; key material is never logged or returned. +#### `Idempotency-Key` + +An upload may carry `Idempotency-Key: <1 to 255 printable ASCII characters, +no spaces>` (an RFC 8941 quoted string is unwrapped). The key is bound, per +publisher, to the exact request: package name, parsed `X-SkillX-Release` +metadata and the sha256 of the body. It is stored in +`release_publish_idempotency` for 24 hours. + +- Same key, same request: the first answer (status and JSON body) is replayed + verbatim with `Idempotent-Replayed: true`; nothing is read from or written + to R2 or `package_releases` again. +- Same key, another request (other bytes, version, channel, kind, provenance or + scripts): `422 idempotency.key_reused`. +- Same key while the first request still runs: `409 idempotency.in_progress` + (a claim left pending by a crashed request is taken over by the same request + after 5 minutes). + +Credentials, the name and the metadata are checked before the key is claimed, +so a rejected request never consumes a key. Answers below `500` are stored; a +`5xx` releases the key so the same request can be retried. Without the header +the route behaves as before (already safe to retry, see above). + +#### Trusted publishing (GitHub Actions OIDC) + +Instead of a stored API key, a GitHub Actions job can authenticate with its +OIDC token. `POST /api/v1/oidc/github/token` with +`{"token": "", "publisher": "@handle"}` (`application/json`, at most +20 KiB) verifies the JWT and answers `200` +`{"token": "sxo_...", "expiresAt", "publisher": "@handle", "scope": "releases:publish:@handle"}` +(`no-store`). Send that token as `Authorization: Bearer sxo_...` to this +route (and to the promote route) for 15 minutes; no other route accepts it. + +The JWT must be RS256-signed by a key of +`https://token.actions.githubusercontent.com/.well-known/jwks` (cached 10 +minutes, refetched for an unknown `kid` at most once a minute), have issuer +`https://token.actions.githubusercontent.com`, audience `SKILLX_OIDC_AUDIENCE` +(default `skillx`), be unexpired (60 s skew, lifetime at most 1 hour) and carry +`jti`, `sub`, `repository`, `repository_id`, `repository_owner_id`, +`workflow_ref`, `ref` and `event_name`. It must match an active trusted +publisher of the publisher (see [Trusted publishers](#trusted-publishers-admin)): +the same `repository_id` and `repository`, a `workflow_ref` of +`/@...`, and the configured `ref` (also the ref of the +workflow file) and `environment` when set. `pull_request*` events are refused. +Each JWT is exchanged once: its `jti` is the primary key of +`oidc_token_exchanges`. + +The `sxo_` token is an HMAC (`SKILLX_TOKEN_SECRET`) over the handle, publisher +id, trusted publisher id, `jti` and expiry. Every use re-reads D1: a revoked +trusted publisher, or a suspended or revoked publisher, stops it at once. + +**Errors**: `400 invalid_request`, `401 oidc.invalid_token`, `403 +oidc.not_trusted` (one answer for every mismatch; it names no configuration), +`403 oidc.event_forbidden`, `403 publisher.inactive`, `404 not_found` (API +disabled), `409 oidc.replayed`, `429 rate_limited`, `503 oidc.unavailable` +(no `SKILLX_TOKEN_SECRET`), `503 oidc.jwks_unavailable`, `503 +rate_limit_unavailable`. Counted on `RELEASE_WRITE_LIMITER` per client IP, +failing closed. + ### Reading releases (pinned whole-tree read API) Read-only, behind the same flag: unless `SKILLX_RELEASES_API` is `"1"` or @@ -1493,6 +1551,122 @@ without `agentkit-exec.json`). Both nullable and additive; the header rollback drops them and deletes the `0014_release_status_and_exec.sql` row from `d1_migrations`. +### Promoting a release between channels + +`POST /api/v1/packages/@{publisher}/{slug}/releases/{version}/promote` with +`{"to": "stable" | "beta" | "alpha", "from"?: , "reason"?: }` +(`application/json`, at most 4 KiB). Moves a **published** release to another +channel, like an npm dist-tag. Only `package_releases.channel` changes: bytes, +digests and the signed manifest stay as published (the manifest's `channel` +records the channel at publish time; the listing, `GET .../releases/{version}` +and the `X-SkillX-Release-Channel` header carry the current one). + +| Caller | May | +|--------|-----| +| Scoped key `releases:publish:@publisher`, or an `sxo_` OIDC publish token | promote only (`alpha` → `beta` → `stable`); demotion is `403 promote.demotion_forbidden` | +| Package owner (session or unscoped API key) | any move | +| Admin (`X-Admin-Secret`) | any move | + +`from` defaults to the current channel. It is compare-and-set: if the channel +changed before the write, `409 state_conflict`. Every attempt on an existing +release writes a `release_state_audit` row (`subject_kind` `release_channel`, +`from_state`/`to_state` are channels, actor `publisher_key` / `user` / +`admin`), denied and invalid attempts included. Counted on +`RELEASE_WRITE_LIMITER` before the body is read. + +**Response** `200` (`no-store`): +`{"identity", "version", "releaseDigest", "from", "to", "channel", "changedAt"}`. + +**Errors**: `400 invalid_request`, `401`, `403 transition.not_owner` / +`promote.demotion_forbidden` / `publisher_revoked` / `auth.scope_missing`, +`404 not_found` (unknown, private and not owned, or API disabled), `409 +promote.not_published` / `state_conflict`, `422 promote.same_channel`, `429 +rate_limited`. + +### Reviewing quarantined releases (operators) + +Admin only (`X-Admin-Secret`; an account is `403 review.admin_only`), counted +on `RELEASE_WRITE_LIMITER`. + +- `GET /api/v1/admin/quarantine`: the review queue, + `{"releases": [{"identity", "version", "channel", "releaseDigest", "quarantinedAt", "lastReview": null | {"decision", "reason", "reviewedAt"}}]}`, + oldest quarantine first, at most 200. +- `POST /api/v1/packages/@{publisher}/{slug}/releases/{version}/review` with + `{"decision": "approve" | "reject", "reason"?: }`. + - `approve` lifts the quarantine: `quarantined` → `yanked` → `published` in + one atomic batch (the status graph has no direct edge, so the signed status + sequence advances by two), one `release_state_audit` row per edge and one + `release_reviews` row, all or nothing. + - `reject` confirms the quarantine: the release stays `quarantined` (its + tree stays blocked in every package) and only a `release_reviews` row is + written. To retire it for good, yank it with the state API + (`quarantined` → `yanked`). + - Both are compare-and-set on `quarantined`: a release that is not (or no + longer) quarantined is `409 state_conflict` and writes nothing. + +**Response** `200`: `{"identity", "version", "releaseDigest", "decision", "state", "reviewedAt"}`. +`release_reviews` is append-only (update and delete triggers abort). + +How each state resolves for readers and clients is the +[release resolution policy](release-resolution-policy.md). + +### Signed artifact download URLs + +For a tool that holds no credentials (a package manager, `curl`), a viewer who +may read a release can mint a short-lived URL: + +- `POST /api/v1/releases/{digest}/artifact/url[?version=&package=]` runs the + normal read path (viewer, read rate limit, the private package and + entitlement rules above, the same `?version=`/`?package=` selectors) and + answers `{"url", "expiresAt", "identity", "version", "releaseDigest", "artifactDigest", "artifactSize"}` + (`no-store`). Other methods are `405`. +- `GET /api/v1/downloads/{token}` streams the artifact (`no-store`). The URL is + valid for 5 minutes. The token is an HMAC (`SKILLX_TOKEN_SECRET`) over the + release digest, identity, version, viewer and expiry; it is not an API key + and grants nothing else. Redemption re-applies the access rules for the same + viewer, so a revoked entitlement, a quarantine, a yank by version or a + suspended publisher stops a URL already issued. A forged, expired or + unverifiable token answers exactly like an unknown release (`404`). + +Without `SKILLX_TOKEN_SECRET`, minting is `503 download_url.unavailable`. The +public artifact route (`GET /api/v1/releases/{digest}/artifact`) is unchanged. + +### Trusted publishers (admin) + +Configuration of the GitHub Actions workflows allowed to publish for a +publisher through OIDC. Admin only (`X-Admin-Secret`; an account is `403 +trusted_publisher.admin_only`), counted on `RELEASE_WRITE_LIMITER`; unknown +handle `404 publisher.not_found`. + +- `GET /api/v1/publishers/{handle}/trusted-publishers` → + `{"trustedPublishers": [...]}`, active and revoked. +- `POST /api/v1/publishers/{handle}/trusted-publishers` with + `{"repository": "owner/repo", "repositoryId": "", "workflowPath": ".github/workflows/.yml", "ref"?: "refs/heads/" | "refs/tags/", "environment"?: "", "reason"?}` + → `{"trustedPublisher", "created"}`. `repositoryId` is GitHub's numeric id + (`gh api repos/OWNER/REPO --jq .id`), as a string: it survives a rename and + stops a re-created repository of the same name from inheriting the trust. + `repository` is stored lowercase. An identical active entry is returned with + `created: false`; at most 20 active entries per publisher (`409 + trusted_publisher.limit`). Omitting `ref` or `environment` accepts any. +- `POST /api/v1/publishers/{handle}/trusted-publishers/{id}/revoke` with + `{"reason"?}` → `{"trustedPublisher"}`; already revoked is `409 + trusted_publisher.already_revoked`. Tokens already issued from it stop + working at once. + +Each creation and revocation writes a `release_state_audit` row +(`subject_kind` `trusted_publisher`, `none` → `active`, `active` → `revoked`). + +### Database tables (migration `0018_registry_publishing`) + +`release_publish_idempotency` (primary key `publisher_id`, `idempotency_key`; +`request_fingerprint`, `status` `pending`/`complete`, `response_status`, +`response_body`, `created_at`, `expires_at`), `release_reviews` (append-only, +triggers `release_reviews_no_update` / `release_reviews_no_delete`), +`trusted_publishers`, and `oidc_token_exchanges` (primary key `jti`). Additive, +no foreign keys; the header rollback drops the triggers, indexes and tables and +deletes the `0018_registry_publishing.sql` row from `d1_migrations`. Numbered +0018 (no 0017 file) because the drizzle journal already holds 18 entries. + --- ## Agent Surfaces diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8facf71..5f9d8ee 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -145,19 +145,22 @@ skillx find "api integration" --- -### `skillx publish [repo]` +### `skillx import [repo]` -Publish skills from a GitHub repo to the SkillX marketplace. +Import (register) skills from a GitHub repo into the SkillX marketplace +(`POST /api/skills/register`). This was `skillx publish [repo]`; +`skillx publish ` still runs it, prints a deprecation +warning on stderr, and will be removed. **Arguments:** -- `[repo]` (optional) — GitHub repo in `owner/repo` format. Auto-detects from git remote if omitted. +- `[repo]` (optional) — `owner/repo` or a GitHub URL. Auto-detects from git remote if omitted. **Options:** | Flag | Description | Default | |------|-------------|---------| | `-p, --path ` | Specific skill subfolder path | — | | `-s, --scan` | Scan entire repo for all SKILL.md files | `false` | -| `--dry-run` | Preview what would be published without calling API | `false` | +| `--dry-run` | Preview what would be imported without calling API | `false` | **Resolution Logic:** - No flags → auto-detect (root SKILL.md or scan all) @@ -169,16 +172,39 @@ Publish skills from a GitHub repo to the SkillX marketplace. **Examples:** ```bash -skillx publish # auto-detect from git remote -skillx publish owner/repo # explicit repo -skillx publish owner/repo --path .claude/skills/my-skill -skillx publish owner/repo --scan -skillx publish --dry-run +skillx import # auto-detect from git remote +skillx import owner/repo # explicit repo +skillx import owner/repo --path .claude/skills/my-skill +skillx import owner/repo --scan +skillx import --dry-run ``` --- -### `skillx release pack ` +### Release commands: `validate`, `pack`, `publish`, `promote` + +These commands work on immutable, digest-pinned releases. With `--json` they +print exactly one envelope on stdout, `{"ok": true, "data": {...}}` or +`{"ok": false, "error": {"code", "message", "details"?, "hint"?}}`, and the +exit code follows it: `0` ok, `1` registry or unexpected error, `2` usage +(invalid input or a package the registry would reject), `3` not found, `5` +credentials missing or rejected, `6` network. `skillx release pack` and +`skillx release publish` remain as aliases of `skillx pack` and +`skillx publish `. + +### `skillx validate [source]` + +Checks a package without uploading: packs it exactly like `pack` (so a tree +the registry would reject fails here), requires a root `SKILL.md` with YAML +frontmatter carrying `name` and `description` (for `--kind skill`), and checks +the optional `--name`, `--release-version`, `--kind` and `--channel`. Lists +every problem in `details.problems` and exits `2`. + +```bash +skillx validate skills/my-skill --name @acme/my-skill --release-version 1.2.0 +``` + +### `skillx pack ` Packs a package from git at an exact commit into a deterministic tar and prints its `releaseDigest` (`agentkit.skill-tree/v1`). Only committed content @@ -186,7 +212,8 @@ is packed: the tree comes from git objects, never the working tree, so mtimes, umask, `core.autocrlf`, `core.fileMode` and uncommitted edits do not change the digest (uncommitted edits are reported as a warning). Modes are normalized to `100644`/`100755`; symlinks and submodules are rejected; OS noise files -(e.g. `.DS_Store`) are excluded and listed. +(e.g. `.DS_Store`) are excluded and listed. Packing the same commit twice gives +the same `releaseDigest`, `artifactDigest` and bytes. - ``: a package directory (packs `HEAD`, path from the repo root) or a git ref used with `--path`. @@ -195,22 +222,26 @@ to `100644`/`100755`; symlinks and submodules are rejected; OS noise files |------|-------------| | `-o, --out ` | Output tar file (required) | | `-p, --path ` | Package directory inside the repository, for a git ref | -| `--json` | Print the result as JSON | +| `--json` | Print the result envelope | ```bash -skillx release pack skills/my-skill -o my-skill.tar -skillx release pack v1.2.0 --path skills/my-skill -o my-skill.tar +skillx pack skills/my-skill -o my-skill.tar +skillx pack v1.2.0 --path skills/my-skill -o my-skill.tar ``` -### `skillx release publish ` +### `skillx publish ` -Packs like `release pack`, then uploads to +Packs like `pack`, then uploads to `POST /api/v1/packages/@publisher/slug/releases`. Needs an API key scoped to -`releases:publish:@publisher` (`SKILLX_API_KEY` or the config). Releases are -immutable: re-publishing identical content is a no-op, different content for -an existing version is rejected. The command fails if the registry reports a -`releaseDigest` (or, for a new release, an `artifactDigest`) other than the -one packed locally. +`releases:publish:@publisher` (`SKILLX_API_KEY` or the config), or `--oidc` in +GitHub Actions (trusted publishing). Releases are immutable: re-publishing +identical content is a no-op, different content for an existing version is +rejected. The command fails if the registry reports a `releaseDigest` (or, for +a new release, an `artifactDigest`) other than the one packed locally. + +A source that is an existing path, or any use of `--name` / +`--release-version`, is a release. Only a GitHub URL or an `owner/repo` that is +not a local path runs the deprecated import (see `skillx import`). | Flag | Description | Default | |------|-------------|---------| @@ -222,17 +253,43 @@ one packed locally. | `--repo ` | Provenance repository URL | `origin` remote (https form) | | `--script ` | Declare a packaged script (repeatable) | — | | `--registry ` | Registry base URL; `https` only (`http` only for localhost) | configured base URL | -| `--json` | Print the result as JSON | — | +| `--idempotency-key ` | Sends `Idempotency-Key`: a retry with the same key and bytes replays the first answer; other bytes are refused | — | +| `--oidc` | Exchange the GitHub Actions OIDC token for a 15 minute publish token (needs `permissions: id-token: write` and a trusted publisher) | — | +| `--oidc-audience ` | Audience requested for the OIDC token | `skillx` | +| `--json` | Print the result envelope | — | ```bash -SKILLX_API_KEY=sx_... skillx release publish skills/my-skill --name @acme/my-skill --release-version 1.2.0 +SKILLX_API_KEY=sx_... skillx publish skills/my-skill --name @acme/my-skill --release-version 1.2.0 +skillx publish HEAD --path skills/my-skill --name @acme/my-skill --release-version 1.2.0 --oidc --idempotency-key "$GITHUB_RUN_ID" ``` CI: `.github/workflows/publish-release.yml` (manual dispatch or `workflow_call`, never on pull requests) runs the same command with a pinned CLI version, in the `skillx-publish` environment, with its -`SKILLX_PUBLISH_KEY` secret. +`SKILLX_PUBLISH_KEY` secret. `.github/workflows/publish-agentkit-foundation.yml` +publishes an AgentKit foundation skill from `bestagentkits/agentkit` at a +pinned commit with OIDC (fallback `SKILLX_AGENTKIT_PUBLISH_KEY`). +### `skillx promote <@publisher/slug@version>` + +Moves a published release to another channel (like an npm dist-tag): only the +current channel changes; bytes, digests and the signed manifest stay as +published. A publish credential (scoped key or `--oidc`) may only promote +(`alpha` → `beta` → `stable`); the package owner's account key may also +demote. Every attempt is audited. + +| Flag | Description | Default | +|------|-------------|---------| +| `--to ` | Target channel (required) | — | +| `--from ` | Expected current channel; the move fails with `state_conflict` if it changed | current | +| `--reason ` | Reason stored in the audit log | — | +| `--registry ` | Registry base URL | configured base URL | +| `--oidc`, `--oidc-audience` | As for `publish` | — | +| `--json` | Print the result envelope | — | + +```bash +skillx promote @acme/my-skill@1.2.0 --from beta --to stable --reason "soak passed" +``` --- ### `skillx report ` @@ -306,9 +363,11 @@ skillx config show | `use` (install) | `/api/skills/{slug}/install` | POST | No | | `find` | `/api/search` | POST | No | | `find` (detail) | `/api/skills/{slug}` | GET | No | -| `publish` | `/api/skills/register` | POST | Yes | +| `import` (and deprecated `publish `) | `/api/skills/register` | POST | Yes | | `report` | `/api/report` | POST | Yes | -| `release publish` | `/api/v1/packages/@{publisher}/{slug}/releases` | POST | Yes (scoped key) | +| `publish ` (`release publish`) | `/api/v1/packages/@{publisher}/{slug}/releases` | POST | Yes (scoped key or `--oidc`) | +| `publish --oidc`, `promote --oidc` | `/api/v1/oidc/github/token` | POST | GitHub Actions OIDC token | +| `promote` | `/api/v1/packages/@{publisher}/{slug}/releases/{version}/promote` | POST | Yes (scoped key, OIDC, or owner key) | **Auth method:** `Authorization: Bearer ` header. diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index c4b4d3f..bb80254 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -300,6 +300,46 @@ var to about `600`; uploads above roughly 580 distinct files are then refused. Stored R2 objects and release rows are immutable and stay in place. +#### Registry publishing additions (migration 0018) + +Idempotent publish, promote, quarantine review, signed download URLs and +GitHub OIDC trusted publishing ship behind the same `SKILLX_RELEASES_API` flag. + +1. **Apply migration `0018_registry_publishing` to the remote D1 before + deploying** (additive: four new tables, their indexes and triggers; no + existing table changes; rollback SQL is in the migration header): + ```bash + cd apps/web + pnpm run db:migrate:remote + ``` +2. Set the token secret (at least 32 characters, random; paste at the prompt). + It signs OIDC publish tokens (`sxo_`, 15 min) and artifact download tokens + (5 min), domain-separated by purpose. Without it the OIDC exchange answers + `503 oidc.unavailable` and private download URLs `503`; everything else works. + ```bash + wrangler secret put SKILLX_TOKEN_SECRET --name skillx-web + ``` + Optional var `SKILLX_OIDC_AUDIENCE` (default `skillx`) must match the + audience the CLI requests (`--oidc-audience`). +3. Register the trusted publisher for the AgentKit foundation workflow (admin + secret; the repository id comes from `gh api repos/nextlevelbuilder/skillx --jq .id`): + ```bash + curl -X POST https://skillx.sh/api/v1/publishers/bestagentkits/trusted-publishers \ + -H "X-Admin-Secret: $ADMIN_SECRET" -H 'Content-Type: application/json' \ + -d '{"repository":"nextlevelbuilder/skillx","repositoryId":"", + "workflowPath":".github/workflows/publish-agentkit-foundation.yml", + "ref":"refs/heads/main","environment":"skillx-publish"}' + ``` +4. GitHub secrets of the `skillx-publish` environment used by + `.github/workflows/publish-agentkit-foundation.yml`: + `SKILLX_AGENTKIT_PUBLISH_KEY` (fallback key scoped to + `releases:publish:@bestagentkits`) and, while `bestagentkits/agentkit` is + private, `AGENTKIT_READ_TOKEN` (read-only contents). + +Rollback: revoke trusted publishers (`POST .../trusted-publishers/{id}/revoke`), +unset `SKILLX_TOKEN_SECRET` (all outstanding `sxo_` and download tokens stop +verifying), then follow the rollback above. + #### Release API activation runbook Run this supervised, by a maintainer, in order. Stop at the first failure. diff --git a/docs/release-resolution-policy.md b/docs/release-resolution-policy.md new file mode 100644 index 0000000..ce1d9a8 --- /dev/null +++ b/docs/release-resolution-policy.md @@ -0,0 +1,64 @@ +# Release resolution policy + +How a release's **state** (`published`, `yanked`, `quarantined`, `draft`) and +**channel** (`stable`, `beta`, `alpha`) decide what a reader gets. The read +API enforces the state rules; clients that choose a version (CLI, MCP tools, +kit lockfiles) follow the channel rules. Source of truth for the HTTP answers: +[Reading releases](api-reference.md#reading-releases-pinned-whole-tree-read-api). + +## States + +| State | Pinned by digest | By version | In the listing | New installs | +|-------|------------------|------------|----------------|--------------| +| `published` | served | served | yes | yes | +| `yanked` | served, `X-SkillX-Release-State: yanked` | `410 release_yanked` | yes, `state: yanked` | no | +| `quarantined` | `403 release_quarantined` | `403 release_quarantined` | hidden | no | +| `draft` | `404` | `404` | hidden | no | + +- **Quarantine is per tree.** If any release with a given `releaseDigest` is + quarantined, every package serving that digest answers `403`, and the + listing hides those releases. Nothing installs or keeps resolving a + quarantined tree, pinned or not. +- **Yank is per release.** A lockfile pinned to the digest keeps working + (existing installs are not broken); resolving by version or choosing "the + latest" must skip it. +- **A suspended or revoked publisher** answers `403 publisher_revoked` for all + its releases, after the quarantine check. +- Unknown and unentitled (private) callers always get `404` first, so a state + never leaks the existence of a private package. + +## Channels + +The channel is a label on a **published** release, moved with +`POST .../releases/{version}/promote` (audited). The signed manifest records +the channel at publish time; the listing, the version route and +`X-SkillX-Release-Channel` carry the current one. + +A client resolving "latest on channel C" must: + +1. consider only releases whose state is `published` (never `yanked`, + `quarantined` or `draft`); +2. accept channels at least as stable as C: `stable` accepts `stable`; `beta` + accepts `beta` and `stable`; `alpha` accepts all three; +3. pick the highest SemVer among them, with SemVer prerelease ordering; +4. pin the result by `releaseDigest`, and re-check the signed status + (`skillx.release-status/v1`) before use. + +The default channel is `stable`. A publisher credential (scoped key or OIDC +publish token) may only promote (`alpha` → `beta` → `stable`); demoting needs +the package owner or an admin. + +## Operator review of quarantine + +A quarantined release waits in `GET /api/v1/admin/quarantine`. An operator +records a decision with `POST .../releases/{version}/review`: + +- **approve** (false positive): `quarantined` → `yanked` → `published` in one + atomic, audited batch; the tree serves again everywhere. +- **reject** (confirmed): the release stays `quarantined`; the decision is + recorded in `release_reviews`. Yanking it afterwards (`quarantined` → + `yanked`) lifts the per-tree block for other packages but keeps this release + out of every by-version and "latest" resolution. + +Every decision and edge is in `release_state_audit` and `release_reviews`, +both append-only. From cd109649350d17e632cafc0b2e2c7bcdbcb40320 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:08:49 +0700 Subject: [PATCH 05/11] ci: publish AgentKit foundations as @bestagentkits/ at the kit version Kit directories ak- map to @bestagentkits/ because @agentkit/* and ak- slugs stay reserved by the skillx.release/v1 contract. The workflow refuses a version that differs from kits//kit.yaml. Co-Authored-By: Claude Opus 5.5 --- .../workflows/publish-agentkit-foundation.yml | 23 +++++++++++++++---- docs/cli-reference.md | 5 +++- docs/deployment-guide.md | 10 ++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-agentkit-foundation.yml b/.github/workflows/publish-agentkit-foundation.yml index b520446..5b89cdf 100644 --- a/.github/workflows/publish-agentkit-foundation.yml +++ b/.github/workflows/publish-agentkit-foundation.yml @@ -8,6 +8,12 @@ # commit; pass `expected-digest` to make a mismatch fail the job before any # upload. # +# Naming: a kit skill directory kits//skills/ak- is published as +# @bestagentkits/ at the version of kits//kit.yaml. @agentkit/* and +# ak-/ak: slugs are reserved by the skillx.release/v1 contract, and the packed +# file set is exactly the skill directory at the pinned commit (the tree +# AgentKit digests), so the releaseDigest matches AgentKit's own digest. +# # Authentication, in order: # 1. GitHub OIDC trusted publishing (`skillx publish --oidc`): the registry # must hold a trusted publisher of @bestagentkits for repository @@ -31,11 +37,11 @@ on: required: true type: string skill-path: - description: "Skill directory inside the agentkit repository, e.g. skills/foo" + description: "Skill directory inside the agentkit repository, e.g. kits/engineer/skills/ak-plan" required: true type: string version: - description: "SemVer release version (no build metadata)" + description: "SemVer release version; for kits//skills/* it must equal kits//kit.yaml version" required: true type: string channel: @@ -45,7 +51,7 @@ on: type: choice options: [stable, beta, alpha] name: - description: "Package identity; default @bestagentkits/" + description: "Package identity; default @bestagentkits/" required: false type: string expected-digest: @@ -83,8 +89,12 @@ jobs: [[ "$SKILL_PATH" =~ ^[A-Za-z0-9._/-]+$ && "$SKILL_PATH" != -* && "$SKILL_PATH" != /* && "$SKILL_PATH" != *..* ]] || { echo "::error::invalid skill-path"; exit 1; } [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::invalid version"; exit 1; } [[ "$CHANNEL" =~ ^(stable|beta|alpha)$ ]] || { echo "::error::invalid channel"; exit 1; } - name="${NAME_INPUT:-@bestagentkits/$(basename "$SKILL_PATH")}" + # Kit skill directories are ak-; SkillX reserves ak-/ak: names, so is the slug. + dir="$(basename "$SKILL_PATH")" + name="${NAME_INPUT:-@bestagentkits/${dir#ak-}}" [[ "$name" =~ ^@bestagentkits/[a-z0-9][a-z0-9._-]*$ ]] || { echo "::error::name must be @bestagentkits/ (lowercase)"; exit 1; } + slug="${name#@bestagentkits/}" + [[ "$slug" != ak-* && "$slug" != ak:* ]] || { echo "::error::slugs starting with ak- or ak: are reserved; publish ak- as @bestagentkits/"; exit 1; } [[ -z "$EXPECTED" || "$EXPECTED" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::expected-digest must be sha256:<64 hex>"; exit 1; } echo "name=$name" >> "$GITHUB_OUTPUT" echo "idempotency-key=agentkit-foundation:$SHA:$name@$VERSION" >> "$GITHUB_OUTPUT" @@ -132,6 +142,11 @@ jobs: run: | set -euo pipefail [[ "$(git rev-parse HEAD)" == "$SHA" ]] || { echo "::error::checked out $(git rev-parse HEAD), expected $SHA"; exit 1; } + # A kit skill (kits//skills/ak-) is released at its kit's version. + if [[ "$SKILL_PATH" =~ ^kits/([^/]+)/skills/[^/]+$ ]]; then + kit_version="$(sed -n 's/^version:[[:space:]]*["'\'']\{0,1\}\([^"'\'' ]*\).*$/\1/p' "kits/${BASH_REMATCH[1]}/kit.yaml" | head -n 1)" + [[ "$kit_version" == "$VERSION" ]] || { echo "::error::version $VERSION differs from kits/${BASH_REMATCH[1]}/kit.yaml version ${kit_version:-}"; exit 1; } + fi cli="$GITHUB_WORKSPACE/skillx/packages/cli/bin/skillx.js" node "$cli" validate HEAD --path "$SKILL_PATH" --name "$NAME" --release-version "$VERSION" --channel "$CHANNEL" --json node "$cli" pack HEAD --path "$SKILL_PATH" --out "$RUNNER_TEMP/release.tar" --json > "$RUNNER_TEMP/pack.json" diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5f9d8ee..532b8fc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -268,7 +268,10 @@ CI: `.github/workflows/publish-release.yml` (manual dispatch or CLI version, in the `skillx-publish` environment, with its `SKILLX_PUBLISH_KEY` secret. `.github/workflows/publish-agentkit-foundation.yml` publishes an AgentKit foundation skill from `bestagentkits/agentkit` at a -pinned commit with OIDC (fallback `SKILLX_AGENTKIT_PUBLISH_KEY`). +pinned commit with OIDC (fallback `SKILLX_AGENTKIT_PUBLISH_KEY`): the kit +directory `kits//skills/ak-` becomes `@bestagentkits/` at the +kit's `kit.yaml` version (see the deployment guide for why `@agentkit/*` stays +reserved). ### `skillx promote <@publisher/slug@version>` diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index bb80254..85aaab8 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -335,6 +335,16 @@ GitHub OIDC trusted publishing ship behind the same `SKILLX_RELEASES_API` flag. `SKILLX_AGENTKIT_PUBLISH_KEY` (fallback key scoped to `releases:publish:@bestagentkits`) and, while `bestagentkits/agentkit` is private, `AGENTKIT_READ_TOKEN` (read-only contents). +5. Naming of AgentKit foundation releases: the kit skill directory + `kits//skills/ak-` is published as `@bestagentkits/` (the `ak-` + prefix is dropped), at the `version` of `kits//kit.yaml`; the workflow + refuses a different version. `@agentkit/*` and `ak-`/`ak:` slugs stay + reserved (`403 identity.reserved_publisher` / `identity.reserved_alias`): + the signed `skillx.release/v1` manifest cannot carry kit provenance, and + AgentKit refuses SkillX releases under `@agentkit`. AgentKit references + these releases as `skillx:bestagentkits/`. The release is the skill + directory byte for byte, so its `releaseDigest` equals the + `agentkit.skill-tree/v1` digest AgentKit computes for that directory. Rollback: revoke trusted publishers (`POST .../trusted-publishers/{id}/revoke`), unset `SKILLX_TOKEN_SECRET` (all outstanding `sxo_` and download tokens stop From 2ce94fe9e93943b58c9d12ca10f9e639c9de4066 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:37:24 +0700 Subject: [PATCH 06/11] fix(releases): keep yanks through quarantine review and harden publish paths - Approving a quarantined release restores the state it had before the quarantine (from release_state_audit): a yanked release stays yanked unless the admin sends restoreTo "published". The approval is pinned to the quarantine it read by quarantined_at. - Idempotency-Key stores only answers that are a function of the request bytes (2xx, 400, 415, 409 release.version_exists) and releases the claim for everything else; the body is read and the caller authorized once. - The OIDC exchange allows only push, workflow_dispatch and release events, keeps verifying with recently fetched JWKS while GitHub is unreachable, and expired exchange and idempotency rows are deleted in small batches. - The current channel header is sent only on the short-lived version route, never on immutable digest routes. Co-Authored-By: Claude Opus 5.5 --- apps/web/app/lib/db/registry-schema.ts | 8 +- apps/web/app/lib/releases/github-oidc.ts | 11 +- apps/web/app/lib/releases/ingest-release.ts | 16 ++- apps/web/app/lib/releases/oidc-exchange.ts | 27 +++- .../app/lib/releases/release-idempotency.ts | 47 +++++-- apps/web/app/lib/releases/release-read.ts | 9 +- .../app/lib/releases/release-review-queue.ts | 41 +++++++ apps/web/app/lib/releases/release-review.ts | 115 +++++++++--------- .../releases/oidc-trusted-publishing.test.ts | 36 +++++- .../releases/release-idempotency.test.ts | 33 ++++- apps/web/tests/releases/release-read.test.ts | 4 + .../web/tests/releases/release-review.test.ts | 19 +++ 12 files changed, 284 insertions(+), 82 deletions(-) create mode 100644 apps/web/app/lib/releases/release-review-queue.ts diff --git a/apps/web/app/lib/db/registry-schema.ts b/apps/web/app/lib/db/registry-schema.ts index ce90430..f8488ff 100644 --- a/apps/web/app/lib/db/registry-schema.ts +++ b/apps/web/app/lib/db/registry-schema.ts @@ -81,10 +81,10 @@ export const releaseStateAudit = sqliteTable( "release_state_audit", { id: text("id").primaryKey(), - subject_kind: text("subject_kind").notNull(), // release | publisher - subject_id: text("subject_id").notNull(), // package_releases.id or publishers.id - actor_kind: text("actor_kind").notNull(), // admin | user - actor_id: text("actor_id").notNull(), // 'admin' (shared ADMIN_SECRET) or the user id + subject_kind: text("subject_kind").notNull(), // release | publisher | release_channel | trusted_publisher + subject_id: text("subject_id").notNull(), // package_releases.id, publishers.id or trusted_publishers.id + actor_kind: text("actor_kind").notNull(), // admin | user | publisher_key (scoped key or OIDC publish token) + actor_id: text("actor_id").notNull(), // 'admin' (shared ADMIN_SECRET), the user id, the API key id, or oidc: from_state: text("from_state").notNull(), // the state the actor expected (compare-and-set); "none" for a publisher creation to_state: text("to_state").notNull(), outcome: text("outcome").notNull(), // applied | stale | denied | invalid diff --git a/apps/web/app/lib/releases/github-oidc.ts b/apps/web/app/lib/releases/github-oidc.ts index e53f6e3..ab09fd1 100644 --- a/apps/web/app/lib/releases/github-oidc.ts +++ b/apps/web/app/lib/releases/github-oidc.ts @@ -16,6 +16,8 @@ export const GITHUB_OIDC_JWKS_URL = `${GITHUB_OIDC_ISSUER}/.well-known/jwks`; export const DEFAULT_OIDC_AUDIENCE = "skillx"; const JWKS_TTL_MS = 10 * 60_000; const JWKS_MIN_REFRESH_MS = 60_000; +/** How long past the TTL cached keys still verify while the JWKS endpoint fails. */ +const JWKS_STALE_GRACE_MS = 60 * 60_000; const CLOCK_SKEW_MS = 60_000; /** GitHub tokens live 5 to 10 minutes; anything longer is not one. */ const MAX_LIFETIME_MS = 60 * 60_000; @@ -70,7 +72,14 @@ async function importJwks(document: unknown): Promise> { async function keyFor(kid: string, fetchJwks: JwksFetcher, now: number): Promise { const stale = cache === null || now - cache.fetchedAt > JWKS_TTL_MS; const unknownKid = cache !== null && !cache.keys.has(kid) && now - cache.fetchedAt > JWKS_MIN_REFRESH_MS; - if (stale || unknownKid) cache = { keys: await importJwks(await fetchJwks()), fetchedAt: now }; + if (stale || unknownKid) { + try { + cache = { keys: await importJwks(await fetchJwks()), fetchedAt: now }; + } catch (error) { + // A GitHub outage: keep verifying with keys fetched within the grace period. + if (cache === null || now - cache.fetchedAt > JWKS_TTL_MS + JWKS_STALE_GRACE_MS) throw error; + } + } return cache!.keys.get(kid); } diff --git a/apps/web/app/lib/releases/ingest-release.ts b/apps/web/app/lib/releases/ingest-release.ts index e21757c..2d15a45 100644 --- a/apps/web/app/lib/releases/ingest-release.ts +++ b/apps/web/app/lib/releases/ingest-release.ts @@ -11,7 +11,7 @@ import { ReleaseManifestError, SKILLX_RELEASE_SCHEMA_VERSION } from "@skillx/contracts"; import { ERROR_CODES, SkillTreeError, readTarTree } from "@skillx/skill-tree"; import type { TreeManifest } from "@skillx/skill-tree"; -import { authorizeReleasePublisher } from "./release-auth"; +import { authorizeReleasePublisher, type AuthorizedPublisher } from "./release-auth"; import { checkExecManifest } from "./release-exec"; import { ReleaseHttpError, errorResponse, releaseError } from "./release-http"; import { BLOB_WRITE_CONCURRENCY, MAX_RELEASE_UPLOAD_BYTES, RELEASE_SERVER_TREE_LIMITS, assertSubrequestBudget } from "./release-limits"; @@ -34,6 +34,10 @@ export interface IngestDeps { tokenSecret?: string | null; } +/** What the idempotent route already read and authorized, so ingest neither buffers the body nor authenticates twice. */ +export type PreparedUpload = { auth: AuthorizedPublisher; body: Uint8Array }; +type Params = { scope?: string; slug?: string }; + interface ReadTree { tree: TreeManifest; contents: Map; @@ -81,9 +85,9 @@ function existingOutcome(identity: string, version: string, existing: ExistingRe }); } -async function ingest(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise { +async function ingest(request: Request, params: Params, deps: IngestDeps, prepared?: PreparedUpload): Promise { const name = parseReleaseName(params.scope, params.slug); - const auth = await authorizeReleasePublisher(deps.db, request, name.handle, { tokenSecret: deps.tokenSecret, now: deps.now?.() }); + const auth = prepared?.auth ?? (await authorizeReleasePublisher(deps.db, request, name.handle, { tokenSecret: deps.tokenSecret, now: deps.now?.() })); const signer = await deps.loadSigner(); const metadata = parseReleaseMetadata(request.headers.get(RELEASE_METADATA_HEADER)); const pkg = await findPackage(deps.db, name.identity); @@ -94,7 +98,7 @@ async function ingest(request: Request, params: { scope?: string; slug?: string throw releaseError(409, "release.kind_conflict", `${name.identity} is a ${pkg.kind} package, not ${metadata.kind}`); } - const body = await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES); + const body = prepared?.body ?? (await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES)); const { tree, contents } = await readTree(body); const existing = await findRelease(deps.db, name.identity, metadata.version); if (existing !== null) return existingOutcome(name.identity, metadata.version, existing, tree.digest); @@ -190,9 +194,9 @@ async function ingest(request: Request, params: { scope?: string; slug?: string } /** Handles one upload; every failure becomes a JSON error with a stable code. */ -export async function ingestRelease(request: Request, params: { scope?: string; slug?: string }, deps: IngestDeps): Promise { +export async function ingestRelease(request: Request, params: Params, deps: IngestDeps, prepared?: PreparedUpload): Promise { try { - return await ingest(request, params, deps); + return await ingest(request, params, deps, prepared); } catch (error) { if (error instanceof ReleaseHttpError) return errorResponse(error); console.error("release publish failed:", error instanceof Error ? error.message : String(error)); diff --git a/apps/web/app/lib/releases/oidc-exchange.ts b/apps/web/app/lib/releases/oidc-exchange.ts index 5e034a9..5a566e1 100644 --- a/apps/web/app/lib/releases/oidc-exchange.ts +++ b/apps/web/app/lib/releases/oidc-exchange.ts @@ -6,8 +6,9 @@ * and must match an active trusted publisher of that publisher: the same * `repository_id` and `repository`, a `workflow_ref` of * `/@...`, and the configured `ref` and - * `environment` when set. Pull request events are refused, so a fork's - * workflow can never publish. Each JWT is exchanged once (its `jti` is the + * `environment` when set. Only `push`, `workflow_dispatch` and `release` + * events may publish (`PUBLISH_EVENTS`), so a fork's pull request or a + * comment-triggered workflow never can. Each JWT is exchanged once (its `jti` is the * primary key of `oidc_token_exchanges`). * * Answers `{token, expiresAt, publisher, scope}`: an `sxo_` token valid for @@ -30,7 +31,15 @@ import { mintOidcPublishToken } from "./trusted-publish-token"; const BODY_FIELDS: ReadonlySet = new Set(["token", "publisher"]); const MAX_BODY_BYTES = 20 * 1024; -const FORBIDDEN_EVENTS = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); +/** + * Events whose workflow runs the trusted ref itself. Everything else + * (`pull_request*`, `workflow_run`, `issue_comment`, `schedule`, ...) can run + * a workflow on behalf of someone without write access, or on a stale ref. + */ +/** How long an exchange row is kept; far beyond any JWT or publish token lifetime. */ +export const EXCHANGE_RETENTION_MS = 7 * 24 * 60 * 60_000; + +export const PUBLISH_EVENTS: ReadonlySet = new Set(["push", "workflow_dispatch", "release"]); export interface OidcExchangeDeps { now?: () => Date; @@ -71,7 +80,9 @@ export async function serveOidcExchange(request: Request, env: Env, deps: OidcEx now, fetchJwks: deps.fetchJwks, }); - if (FORBIDDEN_EVENTS.has(claims.event_name)) throw releaseError(403, "oidc.event_forbidden", "pull request workflows cannot publish"); + if (!PUBLISH_EVENTS.has(claims.event_name)) { + throw releaseError(403, "oidc.event_forbidden", `only push, workflow_dispatch and release workflows can publish, not ${claims.event_name}`); + } const publisher = await env.DB.prepare("SELECT id, state FROM publishers WHERE handle = ?").bind(handle).first<{ id: string; state: string }>(); if (publisher === null) throw notTrusted(); @@ -85,6 +96,14 @@ export async function serveOidcExchange(request: Request, env: Env, deps: OidcEx if (publisher.state !== "active") throw releaseError(403, "publisher.inactive", `publisher @${handle} is ${publisher.state}`); const { token, expiresAt } = await mintOidcPublishToken(secret, { handle, publisherId: publisher.id, trustedPublisherId: trusted.id, jti: claims.jti }, now); + // Opportunistic retention: a jti only blocks replay while its JWT (at most 1 hour) and + // the publish token (15 minutes) live; older rows are kept a week for forensics. + await env.DB.prepare( + `DELETE FROM oidc_token_exchanges WHERE rowid IN (SELECT rowid FROM oidc_token_exchanges WHERE created_at <= ? LIMIT 50)`, + ) + .bind(now.getTime() - EXCHANGE_RETENTION_MS) + .run() + .catch((error: unknown) => console.warn("oidc exchange: cannot delete old exchanges:", error instanceof Error ? error.message : String(error))); const recorded = await env.DB.prepare( `INSERT INTO oidc_token_exchanges (jti, trusted_publisher_id, publisher_id, repository, workflow_ref, run_id, created_at, token_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(jti) DO NOTHING RETURNING jti`, diff --git a/apps/web/app/lib/releases/release-idempotency.ts b/apps/web/app/lib/releases/release-idempotency.ts index 590e8d5..5c48940 100644 --- a/apps/web/app/lib/releases/release-idempotency.ts +++ b/apps/web/app/lib/releases/release-idempotency.ts @@ -13,10 +13,13 @@ * - same key while the first request still runs: `409 idempotency.in_progress`. * * Authentication, the name and the metadata are checked before the key is - * claimed, so a rejected credential never consumes a key. Answers below 500 - * are stored (they are deterministic for the same bytes); a 5xx releases the - * claim so the client may retry with the same key. A claim left pending by a - * crashed request is taken over by the same request after 5 minutes. + * claimed, so a rejected credential never consumes a key. Only answers that + * the same bytes always get are stored (`isReplayable`): 2xx, 400, 415 and + * `409 release.version_exists`. Everything else (5xx, a publisher state or a + * concurrent-create race, an environment-dependent limit such as the + * subrequest budget) releases the claim, so a retry with the same key runs + * again. A claim left pending by a crashed request is taken over by the same + * request after 5 minutes. */ import { ingestRelease } from "./ingest-release"; import type { IngestDeps } from "./ingest-release"; @@ -58,6 +61,24 @@ export async function requestFingerprint(identity: string, metadata: ReleaseMeta type StoredKey = { request_fingerprint: string; status: string; response_status: number | null; response_body: string | null }; +/** Most expired rows one publish deletes on the way, so cleanup never grows a request. */ +export const CLEANUP_BATCH = 50; + +/** Opportunistic cleanup: expired keys of any publisher are dead weight. A failure never blocks the publish. */ +async function deleteExpired(db: D1Database, now: number): Promise { + try { + await db + .prepare( + `DELETE FROM release_publish_idempotency WHERE rowid IN + (SELECT rowid FROM release_publish_idempotency WHERE expires_at <= ? LIMIT ${CLEANUP_BATCH})`, + ) + .bind(now) + .run(); + } catch (error) { + console.warn("release publish: cannot delete expired idempotency keys:", error instanceof Error ? error.message : String(error)); + } +} + /** Claims the key for this request; on conflict reports what holds it. */ async function claim(db: D1Database, publisherId: string, key: string, fingerprint: string, now: number): Promise { const result = await db @@ -84,6 +105,18 @@ async function claim(db: D1Database, publisherId: string, key: string, fingerpri const NO_STORE = { "Cache-Control": "no-store" }; +/** Whether an answer is a pure function of the request bytes, so replaying it is correct. */ +export function isReplayable(status: number, body: string): boolean { + if (status >= 200 && status < 300) return true; + if (status === 400 || status === 415) return true; + if (status !== 409) return false; + try { + return (JSON.parse(body) as { error?: { code?: unknown } }).error?.code === "release.version_exists"; + } catch { + return false; + } +} + function replay(stored: StoredKey): Response { return new Response(stored.response_body, { status: stored.response_status ?? 200, @@ -99,6 +132,7 @@ async function publishOnce(request: Request, params: { scope?: string; slug?: st const body = await readUploadBody(request, MAX_RELEASE_UPLOAD_BYTES); const fingerprint = await requestFingerprint(name.identity, metadata, body); + await deleteExpired(deps.db, now.getTime()); const held = await claim(deps.db, auth.publisherId, key, fingerprint, now.getTime()); if (held !== null) { if (held.request_fingerprint !== fingerprint) { @@ -108,11 +142,10 @@ async function publishOnce(request: Request, params: { scope?: string; slug?: st return replay(held); } - const forwarded = new Request(request.url, { method: "POST", headers: request.headers, body: body as Uint8Array }); - const response = await ingestRelease(forwarded, params, deps); + const response = await ingestRelease(request, params, deps, { auth, body }); const text = await response.text(); const where = "WHERE publisher_id = ? AND idempotency_key = ? AND request_fingerprint = ? AND status = 'pending'"; - if (response.status >= 500) { + if (!isReplayable(response.status, text)) { await deps.db.prepare(`DELETE FROM release_publish_idempotency ${where}`).bind(auth.publisherId, key, fingerprint).run(); } else { await deps.db diff --git a/apps/web/app/lib/releases/release-read.ts b/apps/web/app/lib/releases/release-read.ts index 32fc3ba..7985a2a 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -121,10 +121,16 @@ function stateHeaders(release: ReadableRelease): Record { "X-SkillX-Release-Digest": release.releaseDigest, "X-SkillX-Identity": release.identity, "X-SkillX-Version": release.version, - "X-SkillX-Release-Channel": release.channel, }; } +/** + * The current channel moves with promote and is not signed, so it is sent + * only on short-lived answers, never on the year-long immutable file and + * artifact responses. + */ +const channelHeader = (release: ReadableRelease) => ({ "X-SkillX-Release-Channel": release.channel }); + const DOWNLOAD_HEADERS = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox", @@ -172,6 +178,7 @@ async function manifestResponse(ctx: ReadContext, release: ReadableRelease): Pro "Cache-Control": cacheControl(release, SHORT_TTL_SECONDS, false), "X-Content-Type-Options": "nosniff", ...stateHeaders(release), + ...channelHeader(release), }); } diff --git a/apps/web/app/lib/releases/release-review-queue.ts b/apps/web/app/lib/releases/release-review-queue.ts new file mode 100644 index 0000000..403428c --- /dev/null +++ b/apps/web/app/lib/releases/release-review-queue.ts @@ -0,0 +1,41 @@ +/** The admin review queue: quarantined releases with their latest review decision. */ + +/** Most queue entries one listing returns. */ +export const MAX_QUEUE_ENTRIES = 200; + +type QueueRow = { + name: string; + version: string; + channel: string; + release_digest: string | null; + quarantined_at: number | null; + decision: string | null; + review_reason: string | null; + reviewed_at: number | null; +}; + +/** Quarantined releases, oldest quarantine first, each with its latest review (if any). */ +export async function listQuarantine(db: D1Database) { + const { results } = await db + .prepare( + `SELECT p.name, r.version, r.channel, r.release_digest, r.quarantined_at, + rv.decision, rv.reason AS review_reason, rv.created_at AS reviewed_at + FROM package_releases r + JOIN packages p ON p.id = r.package_id + LEFT JOIN release_reviews rv ON rv.id = ( + SELECT id FROM release_reviews WHERE release_id = r.id ORDER BY created_at DESC, rowid DESC LIMIT 1) + WHERE r.release_state = 'quarantined' + ORDER BY r.quarantined_at, r.id LIMIT ?`, + ) + .bind(MAX_QUEUE_ENTRIES) + .all(); + const iso = (ms: number | null) => (ms === null ? null : new Date(Number(ms)).toISOString()); + return results.map((row) => ({ + identity: row.name, + version: row.version, + channel: row.channel, + releaseDigest: row.release_digest, + quarantinedAt: iso(row.quarantined_at), + lastReview: row.decision === null ? null : { decision: row.decision, reason: row.review_reason, reviewedAt: iso(row.reviewed_at) }, + })); +} diff --git a/apps/web/app/lib/releases/release-review.ts b/apps/web/app/lib/releases/release-review.ts index 29b7025..71bbf4b 100644 --- a/apps/web/app/lib/releases/release-review.ts +++ b/apps/web/app/lib/releases/release-review.ts @@ -6,10 +6,15 @@ * - `POST /api/v1/packages/@handle/slug/releases/:version/review` with * `{"decision": "approve" | "reject", "reason"?}`. * - * `approve` lifts the quarantine: `quarantined -> yanked -> published` in one - * atomic batch (the v1 status graph has no direct edge, so the signed status - * sequence advances by 2), one `release_state_audit` row per edge and one - * `release_reviews` row, all or nothing. `reject` confirms the quarantine: + * `approve` lifts the quarantine and restores the state the release had + * before it (the last applied `release_state_audit` edge into + * `quarantined`): a release quarantined while yanked goes back to `yanked` + * only, so an owner's yank is never undone; `{"restoreTo": "published"}` + * publishes it explicitly. Publishing runs `quarantined -> yanked -> + * published` (the v1 status graph has no direct edge, so the signed status + * sequence advances by 2). One batch writes one audit row per edge and one + * `release_reviews` row, all or nothing, guarded on that audit edge still + * being the latest, so a concurrent change is `409`. `reject` confirms the quarantine: * the release stays `quarantined` (its bytes stay blocked in every package) * and only a `release_reviews` row is written. Both are compare-and-set on * `quarantined`: a release that is no longer quarantined is `409 @@ -20,13 +25,14 @@ import { notFound, parsePackageIdentity, parseReleaseVersion } from "./release-a import { ReleaseHttpError, errorResponse, notFoundResponse, releaseError } from "./release-http"; import { releasesApiEnabled } from "./release-limits"; import { compareAndSet } from "./release-state-cas"; +import { listQuarantine } from "./release-review-queue"; import { NO_STORE, failureHeaders, invalidRequest, methodNotAllowed, parseReason, readJsonObject, resolveLimitedActor } from "./release-state-http"; export const REVIEW_DECISIONS = ["approve", "reject"] as const; export type ReviewDecision = (typeof REVIEW_DECISIONS)[number]; -const BODY_FIELDS: ReadonlySet = new Set(["decision", "reason"]); -/** Most queue entries one listing returns. */ -export const MAX_QUEUE_ENTRIES = 200; +const BODY_FIELDS: ReadonlySet = new Set(["decision", "reason", "restoreTo"]); +export const RESTORE_STATES = ["published", "yanked"] as const; +export type RestoreState = (typeof RESTORE_STATES)[number]; const REVIEW_COLUMNS = "id, release_id, release_digest, decision, actor_kind, actor_id, reason, created_at"; const reviewId = () => `rrv_${crypto.randomUUID()}`; @@ -36,21 +42,45 @@ export interface ReviewResult { version: string; releaseDigest: string | null; decision: ReviewDecision; - state: "published" | "quarantined"; + state: RestoreState | "quarantined"; reviewedAt: string; } -type Subject = { id: string; release_state: string; release_digest: string | null; signed_manifest_key: string | null }; +type Subject = { id: string; release_state: string; release_digest: string | null; signed_manifest_key: string | null; quarantined_at: number | null }; +type Edge = { from_state: string; to_state: string; created_at: number }; + +/** + * The state a quarantined release had before its latest quarantine: the start + * of the last applied audit path into `quarantined` (one batch, so one + * `created_at`; `yanked -> quarantined` runs through `published`). A release + * quarantined at publish time has no such path and counts as `published`. + */ +async function stateBeforeQuarantine(db: D1Database, releaseId: string): Promise { + const { results } = await db + .prepare( + `SELECT from_state, to_state, created_at FROM release_state_audit + WHERE subject_kind = 'release' AND subject_id = ? AND outcome = 'applied' ORDER BY created_at DESC, rowid DESC LIMIT 4`, + ) + .bind(releaseId) + .all(); + if (results.length === 0 || results[0]!.to_state !== "quarantined") return "published"; + let start = results[0]!; + for (const edge of results.slice(1)) { + if (edge.created_at !== start.created_at || edge.to_state !== start.from_state) break; + start = edge; + } + return start.from_state === "yanked" ? "yanked" : "published"; +} export async function reviewRelease( db: D1Database, identity: string, version: string, - input: { decision: ReviewDecision; reason: string | null; now: Date }, + input: { decision: ReviewDecision; reason: string | null; restoreTo?: RestoreState; now: Date }, ): Promise { const subject = await db .prepare( - `SELECT r.id, r.release_state, r.release_digest, r.signed_manifest_key + `SELECT r.id, r.release_state, r.release_digest, r.signed_manifest_key, r.quarantined_at FROM package_releases r JOIN packages p ON p.id = r.package_id WHERE p.name = ? AND r.version = ?`, ) .bind(identity, version) @@ -81,16 +111,19 @@ export async function reviewRelease( return result("quarantined"); } - if (subject.release_digest === null || subject.signed_manifest_key === null) { + const target = input.restoreTo ?? (await stateBeforeQuarantine(db, subject.id)); + if (target === "published" && (subject.release_digest === null || subject.signed_manifest_key === null)) { throw releaseError(422, "release.incomplete", "a release without a tree digest and signed manifest cannot be published"); } + // `quarantined_at` is set by every move into quarantine, so it pins the quarantine this decision read. + const guard = { sql: "EXISTS (SELECT 1 FROM package_releases WHERE id = ? AND quarantined_at IS ?)", binds: [subject.id, subject.quarantined_at] }; const outcome = await compareAndSet( db, - { kind: "release", subjectId: subject.id, input: { from: "quarantined", to: "published", reason: input.reason, actor: { kind: "admin", id: "admin" }, now: input.now } }, + { kind: "release", subjectId: subject.id, input: { from: "quarantined", to: target, reason: input.reason, actor: { kind: "admin", id: "admin" }, now: input.now } }, { - path: ["quarantined", "yanked", "published"], - extraSet: ", published_at = COALESCE(published_at, ?)", - extraBinds: [at], + path: target === "published" ? ["quarantined", "yanked", "published"] : ["quarantined", "yanked"], + ...(target === "published" ? { extraSet: ", published_at = COALESCE(published_at, ?)", extraBinds: [at] } : {}), + guard, alsoInsert: [ { sql: `INSERT INTO release_reviews (${REVIEW_COLUMNS}) SELECT ?, ?, ?, ?, 'admin', 'admin', ?, ?`, @@ -100,44 +133,7 @@ export async function reviewRelease( }, ); if (outcome !== "applied") throw conflict(); - return result("published"); -} - -type QueueRow = { - name: string; - version: string; - channel: string; - release_digest: string | null; - quarantined_at: number | null; - decision: string | null; - review_reason: string | null; - reviewed_at: number | null; -}; - -/** Quarantined releases, oldest quarantine first, each with its latest review (if any). */ -export async function listQuarantine(db: D1Database) { - const { results } = await db - .prepare( - `SELECT p.name, r.version, r.channel, r.release_digest, r.quarantined_at, - rv.decision, rv.reason AS review_reason, rv.created_at AS reviewed_at - FROM package_releases r - JOIN packages p ON p.id = r.package_id - LEFT JOIN release_reviews rv ON rv.id = ( - SELECT id FROM release_reviews WHERE release_id = r.id ORDER BY created_at DESC, rowid DESC LIMIT 1) - WHERE r.release_state = 'quarantined' - ORDER BY r.quarantined_at, r.id LIMIT ?`, - ) - .bind(MAX_QUEUE_ENTRIES) - .all(); - const iso = (ms: number | null) => (ms === null ? null : new Date(Number(ms)).toISOString()); - return results.map((row) => ({ - identity: row.name, - version: row.version, - channel: row.channel, - releaseDigest: row.release_digest, - quarantinedAt: iso(row.quarantined_at), - lastReview: row.decision === null ? null : { decision: row.decision, reason: row.review_reason, reviewedAt: iso(row.reviewed_at) }, - })); + return result(target); } async function requireAdmin(request: Request, env: Env): Promise { @@ -165,7 +161,16 @@ export function serveReview(request: Request, env: Env, params: Record { expect((await exchange(await jwt({ ...baseClaims(), ...override }))).body.error.code).toBe("oidc.not_trusted"); }); - it("refuses pull request events and unknown publishers", async () => { + it.each(["pull_request", "pull_request_target", "workflow_run", "issue_comment", "schedule", "repository_dispatch"])("refuses the %s event", async (event) => { + await configure(TRUST); + const res = await exchange(await jwt({ ...baseClaims(), event_name: event })); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("oidc.event_forbidden"); + }); + + it("drops exchange rows past the retention window", async () => { + await configure(TRUST); + expect((await exchange(await jwt(baseClaims()))).status).toBe(200); + h.d1.raw.prepare("UPDATE oidc_token_exchanges SET created_at = ?").run(NOW.getTime() - 8 * 24 * 60 * 60_000); + expect((await exchange(await jwt(baseClaims()))).status).toBe(200); + expect(h.d1.raw.prepare("SELECT count(*) AS n FROM oidc_token_exchanges").get()).toEqual({ n: 1 }); + }); + + it.each(["push", "release"])("accepts the %s event", async (event) => { + await configure(TRUST); + expect((await exchange(await jwt({ ...baseClaims(), event_name: event }))).status).toBe(200); + }); + + it("keeps verifying with recently fetched keys while the JWKS endpoint fails", async () => { + const at = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000); + const tokenAt = (when: Date) => jwt({ ...baseClaims(), iat: when.getTime() / 1000 - 10, nbf: when.getTime() / 1000 - 10, exp: when.getTime() / 1000 + 290 }); + const down = async () => { + throw new Error("GitHub is down"); + }; + await verifyGithubOidcToken(await tokenAt(NOW), { audience: "skillx", now: NOW, fetchJwks: async () => jwks }); + await expect(verifyGithubOidcToken(await tokenAt(at(30)), { audience: "skillx", now: at(30), fetchJwks: down })).resolves.toMatchObject({ repository_id: "42" }); + await expect(verifyGithubOidcToken(await tokenAt(at(120)), { audience: "skillx", now: at(120), fetchJwks: down })).rejects.toThrow("GitHub is down"); + }); + + it("refuses unknown publishers", async () => { await configure(TRUST); - expect((await exchange(await jwt({ ...baseClaims(), event_name: "pull_request_target" }))).body.error.code).toBe("oidc.event_forbidden"); expect((await exchange(await jwt(baseClaims()), "@alpha")).body.error.code).toBe("oidc.not_trusted"); }); diff --git a/apps/web/tests/releases/release-idempotency.test.ts b/apps/web/tests/releases/release-idempotency.test.ts index 552cff4..b964769 100644 --- a/apps/web/tests/releases/release-idempotency.test.ts +++ b/apps/web/tests/releases/release-idempotency.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { IDEMPOTENCY_TTL_MS, publishRelease } from "~/lib/releases/release-idempotency"; +import { IDEMPOTENCY_TTL_MS, isReplayable, publishRelease } from "~/lib/releases/release-idempotency"; import { NOW, createHarness, file, tarOf } from "./release-harness"; import type { UploadOptions } from "./release-harness"; @@ -75,6 +75,37 @@ describe("release publish Idempotency-Key", () => { expect(rows().map((r) => r.idempotency_key)).not.toContain("v-anon"); }); + it("releases the key for an answer that depends on the environment, so the retry runs again", async () => { + const req = h.request("@alpha/demo", skill() as BodyInit); + req.headers.set("Idempotency-Key", "budget"); + const refused = await publishRelease(req, { scope: "@alpha", slug: "demo" }, { ...h.deps, subrequestBudget: 1 }); + expect(refused.status).toBe(413); + expect(rows()).toEqual([]); + const retried = await send("budget", skill()); + expect(retried.status).toBe(200); + expect(JSON.parse(retried.text).created).toBe(true); + }); + + it("deletes expired keys of any publisher on the way", async () => { + await send("old", skill()); + h.d1.raw.prepare("UPDATE release_publish_idempotency SET expires_at = ?").run(NOW.getTime() - 1); + await send("new", skill()); + expect(rows().map((r) => r.idempotency_key)).toEqual(["new"]); + }); + + it("replays only answers that are a function of the request bytes", () => { + const code = (c: string) => JSON.stringify({ error: { code: c } }); + expect(isReplayable(200, "{}")).toBe(true); + expect(isReplayable(400, code("release.invalid_metadata"))).toBe(true); + expect(isReplayable(415, code("release.unsupported_media_type"))).toBe(true); + expect(isReplayable(409, code("release.version_exists"))).toBe(true); + expect(isReplayable(409, code("release.kind_conflict"))).toBe(false); + expect(isReplayable(403, code("publisher.inactive"))).toBe(false); + expect(isReplayable(413, code("tree.limit_exceeded"))).toBe(false); + expect(isReplayable(422, code("tree.unsafe_path"))).toBe(false); + expect(isReplayable(503, code("release.signing_unavailable"))).toBe(false); + }); + it("rejects a malformed key (400) and keeps the keys of two publishers apart", async () => { const bad = await send("has space", skill()); expect(bad.status).toBe(400); diff --git a/apps/web/tests/releases/release-read.test.ts b/apps/web/tests/releases/release-read.test.ts index c13ec3f..42d497c 100644 --- a/apps/web/tests/releases/release-read.test.ts +++ b/apps/web/tests/releases/release-read.test.ts @@ -436,6 +436,10 @@ describe("release read: release and publisher state", () => { expect(fileRes.headers.get("X-SkillX-Release-State")).toBe("yanked"); const artifact = await api.artifact(digest); expect(artifact.headers.get("X-SkillX-Release-State")).toBe("yanked"); + // The channel moves with promote: only the short-lived manifest answer carries it. + expect(manifest.headers.get("X-SkillX-Release-Channel")).toBe("stable"); + expect(fileRes.headers.get("X-SkillX-Release-Channel")).toBeNull(); + expect(artifact.headers.get("X-SkillX-Release-Channel")).toBeNull(); expect(await bytesOf(artifact)).toEqual(tar); const list = await (await api.list("@alpha/demo")).json(); expect(list.releases).toEqual([expect.objectContaining({ version: "1.0.0", state: "yanked" })]); diff --git a/apps/web/tests/releases/release-review.test.ts b/apps/web/tests/releases/release-review.test.ts index 48646c6..6112aac 100644 --- a/apps/web/tests/releases/release-review.test.ts +++ b/apps/web/tests/releases/release-review.test.ts @@ -72,6 +72,25 @@ describe("quarantine review", () => { expect((await queue()).body.releases).toEqual([]); }); + it("restores a yanked release to yanked unless the admin asks to publish it", async () => { + await publish(); + expect((await change("@alpha/demo", "1.0.0", { from: "published", to: "yanked", reason: "owner yank" }, admin)).status).toBe(200); + expect((await change("@alpha/demo", "1.0.0", { from: "yanked", to: "quarantined", reason: "report" }, admin)).status).toBe(200); + const res = await review({ decision: "approve", reason: "false positive" }); + expect(res.status).toBe(200); + expect(res.body.state).toBe("yanked"); + expect(stateOf("1.0.0")).toBe("yanked"); + expect(audit().at(-1)).toMatchObject({ from_state: "quarantined", to_state: "yanked", outcome: "applied" }); + expect(reviews()).toHaveLength(1); + + expect((await change("@alpha/demo", "1.0.0", { from: "yanked", to: "quarantined", reason: "again" }, admin)).status).toBe(200); + expectError(await review({ decision: "approve", restoreTo: "draft" }), 400, "invalid_request"); + expectError(await review({ decision: "reject", restoreTo: "published" }), 400, "invalid_request"); + const published = await review({ decision: "approve", restoreTo: "published" }); + expect(published.body.state).toBe("published"); + expect(stateOf("1.0.0")).toBe("published"); + }); + it("answers 409 for a release that is not quarantined and writes no review", async () => { await publish(); expectError(await review({ decision: "approve" }), 409, "state_conflict"); From 0a4957f18f1580b92a61edbb418d8fb5f372f2d7 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:37:24 +0700 Subject: [PATCH 07/11] ci: build the CLI outside the publish job and sanitize registry errors The CLI is installed and built in a job without id-token or the skillx-publish environment, and the publish job installs the packed tarball with lifecycle scripts disabled. The API key fallback also covers a registry answering oidc.unavailable, and server text is stripped of line breaks and '::' before it reaches a workflow command. Co-Authored-By: Claude Opus 5.5 --- .../workflows/publish-agentkit-foundation.yml | 87 ++++++++++++++----- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish-agentkit-foundation.yml b/.github/workflows/publish-agentkit-foundation.yml index 5b89cdf..ee92d10 100644 --- a/.github/workflows/publish-agentkit-foundation.yml +++ b/.github/workflows/publish-agentkit-foundation.yml @@ -22,7 +22,13 @@ # and environment skillx-publish (see docs/deployment-guide.md). # 2. Fallback: SKILLX_AGENTKIT_PUBLISH_KEY, an API key scoped to # releases:publish:@bestagentkits, stored as a skillx-publish environment -# secret. Used only when the OIDC exchange is refused (exit code 5). +# secret. Used only when the OIDC exchange is refused (exit code 5) or the +# registry answers oidc.unavailable. +# +# The CLI is built in a separate job without id-token or the environment and +# installed here from its packed tarball with lifecycle scripts disabled. +# Protect the skillx-publish environment: deployment branch main only and +# required reviewers (docs/deployment-guide.md). # Both attempts send the same Idempotency-Key, so a retry never publishes twice. # # AGENTKIT_READ_TOKEN (optional, read-only contents access to @@ -61,18 +67,56 @@ on: permissions: contents: read - id-token: write concurrency: group: skillx-agentkit-foundation-${{ inputs.skill-path }}-${{ inputs.version }} cancel-in-progress: false jobs: - publish: + # Installs and builds without id-token or the skillx-publish environment, so + # no dependency lifecycle script ever runs next to a publish credential. + build-cli: if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Build and pack the skillx CLI + run: | + set -euo pipefail + pnpm install --frozen-lockfile + pnpm --filter skillx-sh build + mkdir -p "$RUNNER_TEMP/cli" + pnpm --filter skillx-sh pack --pack-destination "$RUNNER_TEMP/cli" + mv "$RUNNER_TEMP"/cli/skillx-sh-*.tgz "$RUNNER_TEMP/cli/skillx-sh.tgz" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: skillx-cli + path: ${{ runner.temp }}/cli/skillx-sh.tgz + if-no-files-found: error + retention-days: 1 + + publish: + needs: build-cli + runs-on: ubuntu-latest timeout-minutes: 20 environment: skillx-publish + permissions: + contents: read + id-token: write steps: - name: Validate inputs id: inputs @@ -99,11 +143,6 @@ jobs: echo "name=$name" >> "$GITHUB_OUTPUT" echo "idempotency-key=agentkit-foundation:$SHA:$name@$VERSION" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - path: skillx - persist-credentials: false - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: bestagentkits/agentkit @@ -113,21 +152,20 @@ jobs: persist-credentials: false token: ${{ secrets.AGENTKIT_READ_TOKEN || github.token }} - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - with: - package_json_file: skillx/package.json - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 - cache: pnpm - cache-dependency-path: skillx/pnpm-lock.yaml - - name: Build the skillx CLI - working-directory: skillx + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: skillx-cli + path: ${{ runner.temp }}/cli + + - name: Install the skillx CLI (lifecycle scripts disabled) run: | - pnpm install --frozen-lockfile - pnpm --filter skillx-sh build + set -euo pipefail + npm install --prefix "$RUNNER_TEMP/cli" --no-save --ignore-scripts --omit=dev --no-audit --no-fund "$RUNNER_TEMP/cli/skillx-sh.tgz" + node "$RUNNER_TEMP/cli/node_modules/skillx-sh/bin/skillx.js" --version - name: Validate and pack id: pack @@ -147,7 +185,7 @@ jobs: kit_version="$(sed -n 's/^version:[[:space:]]*["'\'']\{0,1\}\([^"'\'' ]*\).*$/\1/p' "kits/${BASH_REMATCH[1]}/kit.yaml" | head -n 1)" [[ "$kit_version" == "$VERSION" ]] || { echo "::error::version $VERSION differs from kits/${BASH_REMATCH[1]}/kit.yaml version ${kit_version:-}"; exit 1; } fi - cli="$GITHUB_WORKSPACE/skillx/packages/cli/bin/skillx.js" + cli="$RUNNER_TEMP/cli/node_modules/skillx-sh/bin/skillx.js" node "$cli" validate HEAD --path "$SKILL_PATH" --name "$NAME" --release-version "$VERSION" --channel "$CHANNEL" --json node "$cli" pack HEAD --path "$SKILL_PATH" --out "$RUNNER_TEMP/release.tar" --json > "$RUNNER_TEMP/pack.json" digest="$(node -e 'const r=require(process.argv[1]).data; if(!/^sha256:[0-9a-f]{64}$/.test(r.releaseDigest)) process.exit(1); process.stdout.write(r.releaseDigest)' "$RUNNER_TEMP/pack.json")" @@ -170,21 +208,24 @@ jobs: PACK_DIGEST: ${{ steps.pack.outputs.release-digest }} run: | set -euo pipefail - cli="$GITHUB_WORKSPACE/skillx/packages/cli/bin/skillx.js" + cli="$RUNNER_TEMP/cli/node_modules/skillx-sh/bin/skillx.js" publish() { node "$cli" publish HEAD --path "$SKILL_PATH" --name "$NAME" --release-version "$VERSION" \ --channel "$CHANNEL" --repo https://github.com/bestagentkits/agentkit \ --registry https://skillx.sh --idempotency-key "$IDEMPOTENCY_KEY" --json "$@" } + registry_code() { node -e 'try { process.stdout.write(String(require(process.argv[1]).error?.details?.code ?? "")) } catch {}' "$RUNNER_TEMP/release.json"; } status=0 SKILLX_API_KEY= publish --oidc > "$RUNNER_TEMP/release.json" || status=$? - if [[ $status -eq 5 && -n "$SKILLX_AGENTKIT_PUBLISH_KEY" ]]; then - echo "::warning::OIDC trusted publishing was refused; falling back to SKILLX_AGENTKIT_PUBLISH_KEY" + # Exit 5 is a refused exchange; a 503 oidc.unavailable (in error.details.code) is a registry without OIDC configured. + if [[ $status -ne 0 && -n "$SKILLX_AGENTKIT_PUBLISH_KEY" ]] && [[ $status -eq 5 || "$(registry_code)" == "oidc.unavailable" ]]; then + echo "::warning::OIDC trusted publishing was unavailable or refused; falling back to SKILLX_AGENTKIT_PUBLISH_KEY" status=0 SKILLX_API_KEY="$SKILLX_AGENTKIT_PUBLISH_KEY" publish > "$RUNNER_TEMP/release.json" || status=$? fi if [[ $status -ne 0 ]]; then - node -e 'const j=require(process.argv[1]); console.log(`::error::${j.error?.code}: ${j.error?.message}`)' "$RUNNER_TEMP/release.json" || true + # Server text must not open or end a workflow command, so drop '::' and line breaks. + node -e 'const j=require(process.argv[1]); const clean=(v)=>String(v ?? "").replace(/[\r\n]+/g, " ").replace(/:{2,}/g, ":"); console.log(`::error::${clean(j.error?.details?.code ?? j.error?.code)}: ${clean(j.error?.message)}`)' "$RUNNER_TEMP/release.json" || true exit "$status" fi digest="$(node -e 'process.stdout.write(String(require(process.argv[1]).data.releaseDigest))' "$RUNNER_TEMP/release.json")" From 5bc2ba923b22d6a45d064c53fe2f849e04bd1165 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:37:24 +0700 Subject: [PATCH 08/11] docs: correct release API rollout notes and document review and OIDC rules The registry publishing routes are live on deploy (SKILLX_RELEASES_API has been on since #70), so migration 0018 must be applied first, and SKILLX_TOKEN_SECRET gates only OIDC and signed downloads. Documents restoreTo on quarantine approval, the unsigned listing channel, the OIDC event allowlist, the JWKS grace period, retention, the idempotency replay rules and the skillx-publish environment protection. Co-Authored-By: Claude Opus 5.5 --- docs/api-reference.md | 44 ++++++++++++++++++++++--------- docs/deployment-guide.md | 26 +++++++++++++----- docs/release-resolution-policy.md | 23 +++++++++++++--- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b25eb5b..d0c7d7f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1097,7 +1097,8 @@ An upload may carry `Idempotency-Key: <1 to 255 printable ASCII characters, no spaces>` (an RFC 8941 quoted string is unwrapped). The key is bound, per publisher, to the exact request: package name, parsed `X-SkillX-Release` metadata and the sha256 of the body. It is stored in -`release_publish_idempotency` for 24 hours. +`release_publish_idempotency` for 24 hours; each publish also deletes a small +batch of expired keys. - Same key, same request: the first answer (status and JSON body) is replayed verbatim with `Idempotent-Replayed: true`; nothing is read from or written @@ -1109,8 +1110,12 @@ metadata and the sha256 of the body. It is stored in after 5 minutes). Credentials, the name and the metadata are checked before the key is claimed, -so a rejected request never consumes a key. Answers below `500` are stored; a -`5xx` releases the key so the same request can be retried. Without the header +so a rejected request never consumes a key. Only answers that depend on the +request bytes alone are stored and replayed: `2xx`, `400`, `415` and `409 +release.version_exists`. An upload over the size limit is `413` before +any key is claimed. Every other answer (`403`, other `409`s, a `413` tree or +subrequest limit, `422`, `5xx`) releases the key, so the same request runs +again on retry once the cause is fixed. Without the header the route behaves as before (already safe to retry, see above). #### Trusted publishing (GitHub Actions OIDC) @@ -1125,7 +1130,8 @@ route (and to the promote route) for 15 minutes; no other route accepts it. The JWT must be RS256-signed by a key of `https://token.actions.githubusercontent.com/.well-known/jwks` (cached 10 -minutes, refetched for an unknown `kid` at most once a minute), have issuer +minutes, refetched for an unknown `kid` at most once a minute; when a refetch +fails, keys fetched within the last 70 minutes keep verifying), have issuer `https://token.actions.githubusercontent.com`, audience `SKILLX_OIDC_AUDIENCE` (default `skillx`), be unexpired (60 s skew, lifetime at most 1 hour) and carry `jti`, `sub`, `repository`, `repository_id`, `repository_owner_id`, @@ -1133,9 +1139,12 @@ minutes, refetched for an unknown `kid` at most once a minute), have issuer publisher of the publisher (see [Trusted publishers](#trusted-publishers-admin)): the same `repository_id` and `repository`, a `workflow_ref` of `/@...`, and the configured `ref` (also the ref of the -workflow file) and `environment` when set. `pull_request*` events are refused. +workflow file) and `environment` when set. Only `push`, `workflow_dispatch` +and `release` events may publish; any other `event_name` (`pull_request*`, +`workflow_run`, `schedule`, `issue_comment`, ...) is `403 oidc.event_forbidden`. Each JWT is exchanged once: its `jti` is the primary key of -`oidc_token_exchanges`. +`oidc_token_exchanges`, whose rows are kept 7 days (far beyond any accepted +token lifetime) and then deleted in small batches by later exchanges. The `sxo_` token is an HMAC (`SKILLX_TOKEN_SECRET`) over the handle, publisher id, trusted publisher id, `jti` and expiry. Every use re-reads D1: a revoked @@ -1559,7 +1568,10 @@ drops them and deletes the `0014_release_status_and_exec.sql` row from channel, like an npm dist-tag. Only `package_releases.channel` changes: bytes, digests and the signed manifest stay as published (the manifest's `channel` records the channel at publish time; the listing, `GET .../releases/{version}` -and the `X-SkillX-Release-Channel` header carry the current one). +and its `X-SkillX-Release-Channel` header carry the current one). The current +channel is unsigned and mutable, so the immutable digest routes (manifest by +digest, files, artifact) never send that header; see the +[release resolution policy](./release-resolution-policy.md#channels). | Caller | May | |--------|-----| @@ -1592,17 +1604,23 @@ on `RELEASE_WRITE_LIMITER`. `{"releases": [{"identity", "version", "channel", "releaseDigest", "quarantinedAt", "lastReview": null | {"decision", "reason", "reviewedAt"}}]}`, oldest quarantine first, at most 200. - `POST /api/v1/packages/@{publisher}/{slug}/releases/{version}/review` with - `{"decision": "approve" | "reject", "reason"?: }`. - - `approve` lifts the quarantine: `quarantined` → `yanked` → `published` in - one atomic batch (the status graph has no direct edge, so the signed status - sequence advances by two), one `release_state_audit` row per edge and one - `release_reviews` row, all or nothing. + `{"decision": "approve" | "reject", "reason"?: , "restoreTo"?: "published" | "yanked"}`. + - `approve` lifts the quarantine and restores the state the release had + before it, read from `release_state_audit`: a release quarantined while + `published` goes `quarantined` → `yanked` → `published` (the status graph + has no direct edge, so the signed status sequence advances by two); one + quarantined while `yanked` goes `quarantined` → `yanked` and stays yanked. + `restoreTo` overrides that choice (`"published"` publishes a release that + had been yanked). One `release_state_audit` row per edge and one + `release_reviews` row, all or nothing. `restoreTo` with `reject` is + `400 invalid_request`. - `reject` confirms the quarantine: the release stays `quarantined` (its tree stays blocked in every package) and only a `release_reviews` row is written. To retire it for good, yank it with the state API (`quarantined` → `yanked`). - Both are compare-and-set on `quarantined`: a release that is not (or no - longer) quarantined is `409 state_conflict` and writes nothing. + longer) quarantined, or that an approval finds quarantined again since it + read the release, is `409 state_conflict` and writes nothing. **Response** `200`: `{"identity", "version", "releaseDigest", "decision", "state", "reviewedAt"}`. `release_reviews` is append-only (update and delete triggers abort). diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 85aaab8..2288d9c 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -303,19 +303,26 @@ Stored R2 objects and release rows are immutable and stay in place. #### Registry publishing additions (migration 0018) Idempotent publish, promote, quarantine review, signed download URLs and -GitHub OIDC trusted publishing ship behind the same `SKILLX_RELEASES_API` flag. +GitHub OIDC trusted publishing live under the release API. Production has had +`SKILLX_RELEASES_API` set to `"1"` since #70, so these routes are **live as +soon as the worker deploys**; no other flag holds them back. 1. **Apply migration `0018_registry_publishing` to the remote D1 before - deploying** (additive: four new tables, their indexes and triggers; no - existing table changes; rollback SQL is in the migration header): + deploying. This is mandatory**: publish with an `Idempotency-Key`, the OIDC + exchange, trusted publishers and quarantine review read and write these + tables, so a worker deployed first answers `500` on them. The migration is + additive (four new tables, their indexes and triggers; no existing table + changes; rollback SQL is in the migration header): ```bash cd apps/web pnpm run db:migrate:remote ``` 2. Set the token secret (at least 32 characters, random; paste at the prompt). It signs OIDC publish tokens (`sxo_`, 15 min) and artifact download tokens - (5 min), domain-separated by purpose. Without it the OIDC exchange answers - `503 oidc.unavailable` and private download URLs `503`; everything else works. + (5 min), domain-separated by purpose. It gates only those two features: + without it the OIDC exchange answers `503 oidc.unavailable` and signed + download URLs `503`; publishing with API keys, idempotency, promote and + quarantine review work without it. ```bash wrangler secret put SKILLX_TOKEN_SECRET --name skillx-web ``` @@ -330,12 +337,17 @@ GitHub OIDC trusted publishing ship behind the same `SKILLX_RELEASES_API` flag. "workflowPath":".github/workflows/publish-agentkit-foundation.yml", "ref":"refs/heads/main","environment":"skillx-publish"}' ``` -4. GitHub secrets of the `skillx-publish` environment used by +4. Protect the `skillx-publish` GitHub environment (repository Settings → + Environments): deployment branches limited to `main`, and required + reviewers, so a maintainer approves every run. The workflow builds the CLI + in a job without `id-token` or the environment, and installs the packed + CLI in the publish job with lifecycle scripts disabled. +5. GitHub secrets of the `skillx-publish` environment used by `.github/workflows/publish-agentkit-foundation.yml`: `SKILLX_AGENTKIT_PUBLISH_KEY` (fallback key scoped to `releases:publish:@bestagentkits`) and, while `bestagentkits/agentkit` is private, `AGENTKIT_READ_TOKEN` (read-only contents). -5. Naming of AgentKit foundation releases: the kit skill directory +6. Naming of AgentKit foundation releases: the kit skill directory `kits//skills/ak-` is published as `@bestagentkits/` (the `ak-` prefix is dropped), at the `version` of `kits//kit.yaml`; the workflow refuses a different version. `@agentkit/*` and `ak-`/`ak:` slugs stay diff --git a/docs/release-resolution-policy.md b/docs/release-resolution-policy.md index ce1d9a8..9a3b553 100644 --- a/docs/release-resolution-policy.md +++ b/docs/release-resolution-policy.md @@ -31,8 +31,15 @@ kit lockfiles) follow the channel rules. Source of truth for the HTTP answers: The channel is a label on a **published** release, moved with `POST .../releases/{version}/promote` (audited). The signed manifest records -the channel at publish time; the listing, the version route and -`X-SkillX-Release-Channel` carry the current one. +the channel at publish time; the listing, the version route and its +`X-SkillX-Release-Channel` header carry the current one. + +The current channel is an **unsigned listing field**: no signature covers a +promote, so a client treats it as advisory, never as a trust input. It is +mutable, so the immutable digest-addressed routes (`/releases/{digest}/manifest`, +`/files/*`, `/artifact`, cached for a year) never send it; only the +short-lived version route carries the header. A signed channel statement is +tracked in #100. A client resolving "latest on channel C" must: @@ -53,8 +60,16 @@ the package owner or an admin. A quarantined release waits in `GET /api/v1/admin/quarantine`. An operator records a decision with `POST .../releases/{version}/review`: -- **approve** (false positive): `quarantined` → `yanked` → `published` in one - atomic, audited batch; the tree serves again everywhere. +- **approve** (false positive): restores the state the release had before + it was quarantined, read from `release_state_audit`, in one atomic, audited + batch. A release quarantined while `published` goes `quarantined` → + `yanked` → `published` and serves again everywhere. A release quarantined + while `yanked` returns to `yanked` only: an approval never undoes a yank. + To publish it anyway the admin sends + `{"decision":"approve","restoreTo":"published"}` (`restoreTo` is + `published` or `yanked`, valid only with `approve`). The approval answers + `409 state_conflict` when the release was quarantined again after the + operator read it. - **reject** (confirmed): the release stays `quarantined`; the decision is recorded in `release_reviews`. Yanking it afterwards (`quarantined` → `yanked`) lifts the per-tree block for other packages but keeps this release From 5829807de7d40cda4c1e6d4d2e96dcee34bf11e5 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:37:25 +0700 Subject: [PATCH 09/11] feat(cli)!: document the release command envelope and the import rename BREAKING CHANGE: `skillx release publish --json` (now an alias of `skillx publish `) prints the `{"ok": true, "data": {...}}` / `{"ok": false, "error": {...}}` envelope instead of the bare release object, and failures use the documented exit codes instead of always 1. Read the release from `.data`. Co-Authored-By: Claude Opus 5.5 --- packages/cli/README.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index bcc678c..71a1af6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -64,18 +64,30 @@ skillx use something --search # force search mode Use `--raw` to output content with boundary markers (for piping to other tools). -### `skillx publish [owner/repo]` +### `skillx import [owner/repo]` -Publish skills from a GitHub repo to the SkillX marketplace (requires API key + repo ownership). +Import skills from a GitHub repo into the SkillX marketplace (requires API key + repo ownership). +`skillx publish owner/repo` still runs it with a deprecation notice. ```bash -skillx publish # auto-detect from git remote -skillx publish owner/repo # explicit repo -skillx publish owner/repo --path .claude/skills/my-skill # specific skill -skillx publish owner/repo --scan # scan all SKILL.md files -skillx publish --dry-run # preview without publishing +skillx import # auto-detect from git remote +skillx import owner/repo # explicit repo +skillx import owner/repo --path .claude/skills/my-skill # specific skill +skillx import owner/repo --scan # scan all SKILL.md files +skillx import --dry-run # preview without importing ``` +### Release commands: `validate`, `pack`, `publish `, `promote` + +Build and upload immutable, digest-pinned releases. With `--json` each prints one +envelope, `{"ok": true, "data": {...}}` or `{"ok": false, "error": {...}}`, and the +exit code follows it. See the [CLI reference](https://github.com/nextlevelbuilder/skillx/blob/main/docs/cli-reference.md). + +> **Breaking:** `skillx release publish --json` (now an alias of `skillx publish `) +> used to print the bare release object, and failures only as text on stderr with exit +> code `1`. It now prints the envelope: read the result from `.data` and a failure from +> `.error`; the exit code tells the failure kind (`2` usage, `3` not found, `5` auth, `6` network). + ### `skillx report ` Report skill usage outcome (requires API key). From d48626381d9af8232e42fd2ea2b7fb7d43779491 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 20:59:40 +0700 Subject: [PATCH 10/11] fix(releases): read the pre-quarantine state in write order and close token gaps - Approval reads the last two applied audit edges by rowid and treats only the composite yanked -> published -> quarantined batch as a yank, so a yank and a quarantine in the same millisecond no longer re-publish. - Token segments must be canonical base64url, so a MAC has one spelling; the tamper test changes a middle character. - While the JWKS endpoint fails, the refetch is retried at most once a minute instead of on every request. - Indexes for expired-row cleanup on release_publish_idempotency.expires_at and oidc_token_exchanges.created_at, folded into migration 0018 (not applied anywhere yet). Co-Authored-By: Claude Opus 5.5 --- .../app/lib/db/registry-publishing-schema.ts | 10 +++++-- apps/web/app/lib/releases/github-oidc.ts | 12 ++++++--- apps/web/app/lib/releases/oidc-exchange.ts | 9 +++---- apps/web/app/lib/releases/release-review.ts | 24 ++++++++--------- apps/web/app/lib/releases/release-tokens.ts | 4 ++- .../migrations/0018_registry_publishing.sql | 4 +++ .../migrations/meta/0018_snapshot.json | 17 +++++++++++- .../releases/oidc-trusted-publishing.test.ts | 6 +++++ .../releases/release-download-url.test.ts | 7 ++++- .../web/tests/releases/release-review.test.ts | 27 +++++++++++++++++-- docs/api-reference.md | 2 +- docs/release-resolution-policy.md | 5 ++-- 12 files changed, 97 insertions(+), 30 deletions(-) diff --git a/apps/web/app/lib/db/registry-publishing-schema.ts b/apps/web/app/lib/db/registry-publishing-schema.ts index 773af2e..65698ed 100644 --- a/apps/web/app/lib/db/registry-publishing-schema.ts +++ b/apps/web/app/lib/db/registry-publishing-schema.ts @@ -14,7 +14,10 @@ export const releasePublishIdempotency = sqliteTable( created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), expires_at: integer("expires_at", { mode: "timestamp_ms" }).notNull(), }, - (table) => [primaryKey({ columns: [table.publisher_id, table.idempotency_key] })] + (table) => [ + primaryKey({ columns: [table.publisher_id, table.idempotency_key] }), + index("idx_release_publish_idempotency_expires").on(table.expires_at), // expired-row cleanup + ] ); // Operator review decisions on quarantined releases. Append-only (triggers in the migration); @@ -67,5 +70,8 @@ export const oidcTokenExchanges = sqliteTable( created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), token_expires_at: integer("token_expires_at", { mode: "timestamp_ms" }).notNull(), }, - (table) => [index("idx_oidc_token_exchanges_trusted").on(table.trusted_publisher_id, table.created_at)] + (table) => [ + index("idx_oidc_token_exchanges_trusted").on(table.trusted_publisher_id, table.created_at), + index("idx_oidc_token_exchanges_created").on(table.created_at), // retention cleanup + ] ); diff --git a/apps/web/app/lib/releases/github-oidc.ts b/apps/web/app/lib/releases/github-oidc.ts index ab09fd1..2108179 100644 --- a/apps/web/app/lib/releases/github-oidc.ts +++ b/apps/web/app/lib/releases/github-oidc.ts @@ -41,10 +41,12 @@ export type JwksFetcher = () => Promise; const invalid = (message: string) => releaseError(401, "oidc.invalid_token", message); let cache: { keys: Map; fetchedAt: number } | null = null; +let lastFailureAt: number | null = null; /** Test hook: forget the cached JWKS. */ export function resetJwksCache(): void { cache = null; + lastFailureAt = null; } export const fetchGithubJwks: JwksFetcher = async () => { @@ -72,12 +74,16 @@ async function importJwks(document: unknown): Promise> { async function keyFor(kid: string, fetchJwks: JwksFetcher, now: number): Promise { const stale = cache === null || now - cache.fetchedAt > JWKS_TTL_MS; const unknownKid = cache !== null && !cache.keys.has(kid) && now - cache.fetchedAt > JWKS_MIN_REFRESH_MS; - if (stale || unknownKid) { + const usable = cache !== null && now - cache.fetchedAt <= JWKS_TTL_MS + JWKS_STALE_GRACE_MS; + // During a GitHub outage, retry at most once a minute and verify with the cached keys meanwhile. + const backingOff = usable && lastFailureAt !== null && now - lastFailureAt < JWKS_MIN_REFRESH_MS; + if ((stale || unknownKid) && !backingOff) { try { cache = { keys: await importJwks(await fetchJwks()), fetchedAt: now }; + lastFailureAt = null; } catch (error) { - // A GitHub outage: keep verifying with keys fetched within the grace period. - if (cache === null || now - cache.fetchedAt > JWKS_TTL_MS + JWKS_STALE_GRACE_MS) throw error; + lastFailureAt = now; + if (!usable) throw error; } } return cache!.keys.get(kid); diff --git a/apps/web/app/lib/releases/oidc-exchange.ts b/apps/web/app/lib/releases/oidc-exchange.ts index 5a566e1..53e9ec3 100644 --- a/apps/web/app/lib/releases/oidc-exchange.ts +++ b/apps/web/app/lib/releases/oidc-exchange.ts @@ -31,14 +31,13 @@ import { mintOidcPublishToken } from "./trusted-publish-token"; const BODY_FIELDS: ReadonlySet = new Set(["token", "publisher"]); const MAX_BODY_BYTES = 20 * 1024; +/** How long an exchange row is kept; far beyond any JWT or publish token lifetime. */ +export const EXCHANGE_RETENTION_MS = 7 * 24 * 60 * 60_000; /** * Events whose workflow runs the trusted ref itself. Everything else * (`pull_request*`, `workflow_run`, `issue_comment`, `schedule`, ...) can run * a workflow on behalf of someone without write access, or on a stale ref. */ -/** How long an exchange row is kept; far beyond any JWT or publish token lifetime. */ -export const EXCHANGE_RETENTION_MS = 7 * 24 * 60 * 60_000; - export const PUBLISH_EVENTS: ReadonlySet = new Set(["push", "workflow_dispatch", "release"]); export interface OidcExchangeDeps { @@ -81,8 +80,8 @@ export async function serveOidcExchange(request: Request, env: Env, deps: OidcEx fetchJwks: deps.fetchJwks, }); if (!PUBLISH_EVENTS.has(claims.event_name)) { - throw releaseError(403, "oidc.event_forbidden", `only push, workflow_dispatch and release workflows can publish, not ${claims.event_name}`); - } + throw releaseError(403, "oidc.event_forbidden", `only push, workflow_dispatch and release workflows can publish, not ${claims.event_name}`); + } const publisher = await env.DB.prepare("SELECT id, state FROM publishers WHERE handle = ?").bind(handle).first<{ id: string; state: string }>(); if (publisher === null) throw notTrusted(); diff --git a/apps/web/app/lib/releases/release-review.ts b/apps/web/app/lib/releases/release-review.ts index 71bbf4b..6884865 100644 --- a/apps/web/app/lib/releases/release-review.ts +++ b/apps/web/app/lib/releases/release-review.ts @@ -50,26 +50,26 @@ type Subject = { id: string; release_state: string; release_digest: string | nul type Edge = { from_state: string; to_state: string; created_at: number }; /** - * The state a quarantined release had before its latest quarantine: the start - * of the last applied audit path into `quarantined` (one batch, so one - * `created_at`; `yanked -> quarantined` runs through `published`). A release - * quarantined at publish time has no such path and counts as `published`. + * The state a quarantined release had before its latest quarantine, read from + * the last two applied audit edges in write order (rowid; the table is + * append-only). Quarantining a yanked release is the one composite batch + * `yanked -> published -> quarantined`, so exactly that pair, written by one + * batch (same `created_at`), means `yanked`. Anything else counts as + * `published`, including a release quarantined at publish time. */ async function stateBeforeQuarantine(db: D1Database, releaseId: string): Promise { const { results } = await db .prepare( `SELECT from_state, to_state, created_at FROM release_state_audit - WHERE subject_kind = 'release' AND subject_id = ? AND outcome = 'applied' ORDER BY created_at DESC, rowid DESC LIMIT 4`, + WHERE subject_kind = 'release' AND subject_id = ? AND outcome = 'applied' ORDER BY rowid DESC LIMIT 2`, ) .bind(releaseId) .all(); - if (results.length === 0 || results[0]!.to_state !== "quarantined") return "published"; - let start = results[0]!; - for (const edge of results.slice(1)) { - if (edge.created_at !== start.created_at || edge.to_state !== start.from_state) break; - start = edge; - } - return start.from_state === "yanked" ? "yanked" : "published"; + const [last, before] = results; + if (last === undefined || last.to_state !== "quarantined") return "published"; + if (last.from_state === "yanked") return "yanked"; + const composite = last.from_state === "published" && before?.from_state === "yanked" && before.to_state === "published" && before.created_at === last.created_at; + return composite ? "yanked" : "published"; } export async function reviewRelease( diff --git a/apps/web/app/lib/releases/release-tokens.ts b/apps/web/app/lib/releases/release-tokens.ts index e189ce1..c7ea6f1 100644 --- a/apps/web/app/lib/releases/release-tokens.ts +++ b/apps/web/app/lib/releases/release-tokens.ts @@ -22,11 +22,13 @@ function base64url(bytes: Uint8Array): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } +/** Decodes canonical base64url only: a text whose unused trailing bits are set is refused, so one token has one spelling. */ function fromBase64url(text: string): Uint8Array | null { if (!/^[A-Za-z0-9_-]*$/.test(text)) return null; try { const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (text.length % 4)) % 4); - return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + return base64url(bytes) === text ? bytes : null; } catch { return null; } diff --git a/apps/web/drizzle/migrations/0018_registry_publishing.sql b/apps/web/drizzle/migrations/0018_registry_publishing.sql index 108ebfe..cc54206 100644 --- a/apps/web/drizzle/migrations/0018_registry_publishing.sql +++ b/apps/web/drizzle/migrations/0018_registry_publishing.sql @@ -18,7 +18,9 @@ -- DROP TABLE IF EXISTS trusted_publishers; -- DROP INDEX IF EXISTS idx_release_reviews_release; -- DROP TABLE IF EXISTS release_reviews; +-- DROP INDEX IF EXISTS idx_release_publish_idempotency_expires; -- DROP TABLE IF EXISTS release_publish_idempotency; +-- DROP INDEX IF EXISTS idx_oidc_token_exchanges_created; -- DROP INDEX IF EXISTS idx_oidc_token_exchanges_trusted; -- DROP TABLE IF EXISTS oidc_token_exchanges; -- DELETE FROM d1_migrations WHERE name = '0018_registry_publishing.sql'; @@ -33,6 +35,7 @@ CREATE TABLE `oidc_token_exchanges` ( `token_expires_at` integer NOT NULL ); --> statement-breakpoint +CREATE INDEX `idx_oidc_token_exchanges_created` ON `oidc_token_exchanges` (`created_at`);--> statement-breakpoint CREATE INDEX `idx_oidc_token_exchanges_trusted` ON `oidc_token_exchanges` (`trusted_publisher_id`,`created_at`);--> statement-breakpoint CREATE TABLE `release_publish_idempotency` ( `publisher_id` text NOT NULL, @@ -46,6 +49,7 @@ CREATE TABLE `release_publish_idempotency` ( PRIMARY KEY(`publisher_id`, `idempotency_key`) ); --> statement-breakpoint +CREATE INDEX `idx_release_publish_idempotency_expires` ON `release_publish_idempotency` (`expires_at`);--> statement-breakpoint CREATE TABLE `release_reviews` ( `id` text PRIMARY KEY NOT NULL, `release_id` text NOT NULL, diff --git a/apps/web/drizzle/migrations/meta/0018_snapshot.json b/apps/web/drizzle/migrations/meta/0018_snapshot.json index 142c581..f70d19f 100644 --- a/apps/web/drizzle/migrations/meta/0018_snapshot.json +++ b/apps/web/drizzle/migrations/meta/0018_snapshot.json @@ -1902,6 +1902,13 @@ } }, "indexes": { + "idx_oidc_token_exchanges_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, "idx_oidc_token_exchanges_trusted": { "name": "idx_oidc_token_exchanges_trusted", "columns": [ @@ -1976,7 +1983,15 @@ "autoincrement": false } }, - "indexes": {}, + "indexes": { + "idx_release_publish_idempotency_expires": { + "name": "idx_release_publish_idempotency_expires", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, "foreignKeys": {}, "compositePrimaryKeys": { "release_publish_idempotency_publisher_id_idempotency_key_pk": { diff --git a/apps/web/tests/releases/oidc-trusted-publishing.test.ts b/apps/web/tests/releases/oidc-trusted-publishing.test.ts index 1a4f803..7101722 100644 --- a/apps/web/tests/releases/oidc-trusted-publishing.test.ts +++ b/apps/web/tests/releases/oidc-trusted-publishing.test.ts @@ -144,11 +144,17 @@ describe("OIDC exchange", () => { it("keeps verifying with recently fetched keys while the JWKS endpoint fails", async () => { const at = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000); const tokenAt = (when: Date) => jwt({ ...baseClaims(), iat: when.getTime() / 1000 - 10, nbf: when.getTime() / 1000 - 10, exp: when.getTime() / 1000 + 290 }); + let attempts = 0; const down = async () => { + attempts += 1; throw new Error("GitHub is down"); }; await verifyGithubOidcToken(await tokenAt(NOW), { audience: "skillx", now: NOW, fetchJwks: async () => jwks }); await expect(verifyGithubOidcToken(await tokenAt(at(30)), { audience: "skillx", now: at(30), fetchJwks: down })).resolves.toMatchObject({ repository_id: "42" }); + await verifyGithubOidcToken(await tokenAt(at(30.5)), { audience: "skillx", now: at(30.5), fetchJwks: down }); + expect(attempts).toBe(1); + await verifyGithubOidcToken(await tokenAt(at(31.5)), { audience: "skillx", now: at(31.5), fetchJwks: down }); + expect(attempts).toBe(2); await expect(verifyGithubOidcToken(await tokenAt(at(120)), { audience: "skillx", now: at(120), fetchJwks: down })).rejects.toThrow("GitHub is down"); }); diff --git a/apps/web/tests/releases/release-download-url.test.ts b/apps/web/tests/releases/release-download-url.test.ts index 0fd940c..ce140de 100644 --- a/apps/web/tests/releases/release-download-url.test.ts +++ b/apps/web/tests/releases/release-download-url.test.ts @@ -60,8 +60,13 @@ describe("signed artifact download URLs", () => { const digest = await publish(); const { url } = (await mint(digest)).body; expect((await redeem(url, new Date(Date.now() + DOWNLOAD_URL_TTL_MS + 1000))).status).toBe(404); - const tampered = url.replace(/.$/, (c: string) => (c === "A" ? "B" : "A")); + const at = url.lastIndexOf(".") + 10; + const tampered = url.slice(0, at) + (url[at] === "A" ? "B" : "A") + url.slice(at + 1); expect((await redeem(tampered)).status).toBe(404); + // The last of 43 MAC characters carries two unused bits; another spelling of the same MAC is refused too. + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const respelled = url.slice(0, -1) + alphabet[alphabet.indexOf(url.at(-1)!) ^ 1]; + expect((await redeem(respelled)).status).toBe(404); expect((await redeem(url, new Date(), { SKILLX_TOKEN_SECRET: "another-secret-0123456789abcdefghij" })).status).toBe(404); expect((await redeem(url, new Date(), {})).status).toBe(404); }); diff --git a/apps/web/tests/releases/release-review.test.ts b/apps/web/tests/releases/release-review.test.ts index 6112aac..9b31311 100644 --- a/apps/web/tests/releases/release-review.test.ts +++ b/apps/web/tests/releases/release-review.test.ts @@ -1,7 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { action as reviewAction } from "~/routes/api.v1.release-review"; import { loader as queueLoader } from "~/routes/api.v1.admin-quarantine"; -import { ADMIN, audit, change, envOf, expectError, publish, sql, stateOf, useStateHarness } from "./release-state-helpers"; +import { ADMIN, audit, change, envOf, expectError, holdBatches, publish, sql, stateOf, useStateHarness } from "./release-state-helpers"; import type { Harness } from "./release-state-helpers"; const session = vi.hoisted(() => ({ user: null as { id: string } | null })); @@ -18,6 +18,10 @@ beforeEach(async () => { ownerKey = await h.createKey("user-a", null); }); +afterEach(() => { + vi.useRealTimers(); +}); + const admin = { admin: ADMIN }; async function review(body: unknown, auth: { key?: string; admin?: string } = admin) { @@ -91,6 +95,25 @@ describe("quarantine review", () => { expect(stateOf("1.0.0")).toBe("published"); }); + it("keeps a yank when the yank and the quarantine land in the same millisecond", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-19T12:00:00Z")); + await publish(); + expect((await change("@alpha/demo", "1.0.0", { from: "published", to: "yanked", reason: "owner yank" }, admin)).status).toBe(200); + expect((await change("@alpha/demo", "1.0.0", { from: "yanked", to: "quarantined", reason: "report" }, admin)).status).toBe(200); + const res = await review({ decision: "approve" }); + expect(res.body.state).toBe("yanked"); + expect(stateOf("1.0.0")).toBe("yanked"); + }); + + it("refuses an approval when the release was quarantined again after it was read", async () => { + await quarantine(); + holdBatches(1, () => sql("UPDATE package_releases SET quarantined_at = quarantined_at + 1")); + expectError(await review({ decision: "approve" }), 409, "state_conflict"); + expect(stateOf("1.0.0")).toBe("quarantined"); + expect(reviews()).toEqual([]); + }); + it("answers 409 for a release that is not quarantined and writes no review", async () => { await publish(); expectError(await review({ decision: "approve" }), 409, "state_conflict"); diff --git a/docs/api-reference.md b/docs/api-reference.md index d0c7d7f..878280f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1131,7 +1131,7 @@ route (and to the promote route) for 15 minutes; no other route accepts it. The JWT must be RS256-signed by a key of `https://token.actions.githubusercontent.com/.well-known/jwks` (cached 10 minutes, refetched for an unknown `kid` at most once a minute; when a refetch -fails, keys fetched within the last 70 minutes keep verifying), have issuer +fails, keys fetched within the last 70 minutes keep verifying and the fetch is retried at most once a minute), have issuer `https://token.actions.githubusercontent.com`, audience `SKILLX_OIDC_AUDIENCE` (default `skillx`), be unexpired (60 s skew, lifetime at most 1 hour) and carry `jti`, `sub`, `repository`, `repository_id`, `repository_owner_id`, diff --git a/docs/release-resolution-policy.md b/docs/release-resolution-policy.md index 9a3b553..56088f0 100644 --- a/docs/release-resolution-policy.md +++ b/docs/release-resolution-policy.md @@ -61,8 +61,9 @@ A quarantined release waits in `GET /api/v1/admin/quarantine`. An operator records a decision with `POST .../releases/{version}/review`: - **approve** (false positive): restores the state the release had before - it was quarantined, read from `release_state_audit`, in one atomic, audited - batch. A release quarantined while `published` goes `quarantined` → + it was quarantined, read from the last two `release_state_audit` edges in + write order (quarantining a yanked release writes `yanked` → `published` → + `quarantined` in one batch), in one atomic, audited batch. A release quarantined while `published` goes `quarantined` → `yanked` → `published` and serves again everywhere. A release quarantined while `yanked` returns to `yanked` only: an approval never undoes a yank. To publish it anyway the admin sends From f86276d9c5d4c7ee00fbeab93828a6883ade4db7 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Mon, 19 Oct 2026 21:08:42 +0700 Subject: [PATCH 11/11] docs(releases): describe the review approval read and guard as implemented Co-Authored-By: Claude Opus 5.5 --- apps/web/app/lib/releases/release-review.ts | 20 +++++++++++--------- docs/api-reference.md | 3 ++- docs/release-resolution-policy.md | 7 ++++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/web/app/lib/releases/release-review.ts b/apps/web/app/lib/releases/release-review.ts index 6884865..6fe5e2c 100644 --- a/apps/web/app/lib/releases/release-review.ts +++ b/apps/web/app/lib/releases/release-review.ts @@ -7,15 +7,17 @@ * `{"decision": "approve" | "reject", "reason"?}`. * * `approve` lifts the quarantine and restores the state the release had - * before it (the last applied `release_state_audit` edge into - * `quarantined`): a release quarantined while yanked goes back to `yanked` - * only, so an owner's yank is never undone; `{"restoreTo": "published"}` - * publishes it explicitly. Publishing runs `quarantined -> yanked -> - * published` (the v1 status graph has no direct edge, so the signed status - * sequence advances by 2). One batch writes one audit row per edge and one - * `release_reviews` row, all or nothing, guarded on that audit edge still - * being the latest, so a concurrent change is `409`. `reject` confirms the quarantine: - * the release stays `quarantined` (its bytes stay blocked in every package) + * before it, read from the last two applied `release_state_audit` edges in + * write order (rowid): a release quarantined while yanked (the composite + * `yanked -> published -> quarantined` batch) goes back to `yanked` only, so + * an owner's yank is never undone; `{"restoreTo": "published"}` publishes it + * explicitly. Publishing runs `quarantined -> yanked -> published` (the v1 + * status graph has no direct edge, so the signed status sequence advances by + * 2). One batch writes one audit row per edge and one `release_reviews` row, + * all or nothing, guarded on `quarantined_at` being the value the approval + * read, so a re-quarantine in between is `409`. `reject` confirms the + * quarantine: the release stays `quarantined` (its bytes stay blocked in + * every package) * and only a `release_reviews` row is written. Both are compare-and-set on * `quarantined`: a release that is no longer quarantined is `409 * state_conflict`. Accounts get 403. Requests are counted on diff --git a/docs/api-reference.md b/docs/api-reference.md index 878280f..b2ad9f5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1131,7 +1131,8 @@ route (and to the promote route) for 15 minutes; no other route accepts it. The JWT must be RS256-signed by a key of `https://token.actions.githubusercontent.com/.well-known/jwks` (cached 10 minutes, refetched for an unknown `kid` at most once a minute; when a refetch -fails, keys fetched within the last 70 minutes keep verifying and the fetch is retried at most once a minute), have issuer +fails, keys fetched within the last 70 minutes keep verifying and the fetch is +retried at most once a minute), have issuer `https://token.actions.githubusercontent.com`, audience `SKILLX_OIDC_AUDIENCE` (default `skillx`), be unexpired (60 s skew, lifetime at most 1 hour) and carry `jti`, `sub`, `repository`, `repository_id`, `repository_owner_id`, diff --git a/docs/release-resolution-policy.md b/docs/release-resolution-policy.md index 56088f0..f5b4ffb 100644 --- a/docs/release-resolution-policy.md +++ b/docs/release-resolution-policy.md @@ -63,9 +63,10 @@ records a decision with `POST .../releases/{version}/review`: - **approve** (false positive): restores the state the release had before it was quarantined, read from the last two `release_state_audit` edges in write order (quarantining a yanked release writes `yanked` → `published` → - `quarantined` in one batch), in one atomic, audited batch. A release quarantined while `published` goes `quarantined` → - `yanked` → `published` and serves again everywhere. A release quarantined - while `yanked` returns to `yanked` only: an approval never undoes a yank. + `quarantined` in one batch), in one atomic, audited batch. A release + quarantined while `published` goes `quarantined` → `yanked` → `published` + and serves again everywhere. A release quarantined while `yanked` returns + to `yanked` only: an approval never undoes a yank. To publish it anyway the admin sends `{"decision":"approve","restoreTo":"published"}` (`restoreTo` is `published` or `yanked`, valid only with `approve`). The approval answers