diff --git a/.github/workflows/publish-agentkit-foundation.yml b/.github/workflows/publish-agentkit-foundation.yml new file mode 100644 index 0000000..ee92d10 --- /dev/null +++ b/.github/workflows/publish-agentkit-foundation.yml @@ -0,0 +1,240 @@ +# 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. +# +# 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 +# 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) 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 +# 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. kits/engineer/skills/ak-plan" + required: true + type: string + version: + description: "SemVer release version; for kits//skills/* it must equal kits//kit.yaml version" + 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 + +concurrency: + group: skillx-agentkit-foundation-${{ inputs.skill-path }}-${{ inputs.version }} + cancel-in-progress: false + +jobs: + # 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 + 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; } + # 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" + + - 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: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - 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: | + 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 + 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; } + # 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="$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")" + 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="$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=$? + # 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 + # 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")" + 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" 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/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..65698ed --- /dev/null +++ b/apps/web/app/lib/db/registry-publishing-schema.ts @@ -0,0 +1,77 @@ +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] }), + 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); +// 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), + index("idx_oidc_token_exchanges_created").on(table.created_at), // retention cleanup + ] +); 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/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..2108179 --- /dev/null +++ b/apps/web/app/lib/releases/github-oidc.ts @@ -0,0 +1,157 @@ +/** + * 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; +/** 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; +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; +let lastFailureAt: number | null = null; + +/** Test hook: forget the cached JWKS. */ +export function resetJwksCache(): void { + cache = null; + lastFailureAt = 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; + 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) { + lastFailureAt = now; + if (!usable) throw error; + } + } + 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..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"; @@ -30,8 +30,14 @@ 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; } +/** 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; @@ -79,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); + 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); @@ -92,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); @@ -188,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 new file mode 100644 index 0000000..53e9ec3 --- /dev/null +++ b/apps/web/app/lib/releases/oidc-exchange.ts @@ -0,0 +1,121 @@ +/** + * `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. 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 + * 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; +/** 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. + */ +export const PUBLISH_EVENTS: ReadonlySet = new Set(["push", "workflow_dispatch", "release"]); + +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 (!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(); + 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); + // 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`, + ) + .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..5c48940 --- /dev/null +++ b/apps/web/app/lib/releases/release-idempotency.ts @@ -0,0 +1,170 @@ +/** + * `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. 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"; +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 }; + +/** 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 + .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" }; + +/** 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, + 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); + + 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) { + 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 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 (!isReplayable(response.status, text)) { + 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..7985a2a 100644 --- a/apps/web/app/lib/releases/release-read.ts +++ b/apps/web/app/lib/releases/release-read.ts @@ -124,6 +124,13 @@ function stateHeaders(release: ReadableRelease): Record { }; } +/** + * 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", @@ -171,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), }); } @@ -208,7 +216,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 +298,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 +316,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-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 new file mode 100644 index 0000000..6fe5e2c --- /dev/null +++ b/apps/web/app/lib/releases/release-review.ts @@ -0,0 +1,186 @@ +/** + * 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 and restores the state the release had + * 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 + * `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 { 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", "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()}`; + +export interface ReviewResult { + identity: string; + version: string; + releaseDigest: string | null; + decision: ReviewDecision; + state: RestoreState | "quarantined"; + reviewedAt: string; +} + +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, 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 rowid DESC LIMIT 2`, + ) + .bind(releaseId) + .all(); + 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( + db: D1Database, + identity: string, + version: string, + 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, 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) + .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"); + } + + 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: target, reason: input.reason, actor: { kind: "admin", id: "admin" }, now: input.now } }, + { + 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', ?, ?`, + binds: [reviewId(), subject.id, subject.release_digest, decided, input.reason, at], + }, + ], + }, + ); + if (outcome !== "applied") throw conflict(); + return result(target); +} + +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(", ")}`); + if (body.restoreTo !== undefined && !(RESTORE_STATES as readonly unknown[]).includes(body.restoreTo)) { + throw invalidRequest(`restoreTo must be one of: ${RESTORE_STATES.join(", ")}`); + } + if (body.restoreTo !== undefined && body.decision !== "approve") throw invalidRequest("restoreTo applies only to approve"); + const result = await reviewRelease(env.DB, identity, version, { + decision: body.decision as ReviewDecision, + reason: parseReason(body.reason), + restoreTo: body.restoreTo as RestoreState | undefined, + 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..c7ea6f1 --- /dev/null +++ b/apps/web/app/lib/releases/release-tokens.ts @@ -0,0 +1,75 @@ +/** + * 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(/=+$/, ""); +} + +/** 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); + const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + return base64url(bytes) === text ? bytes : null; + } 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..cc54206 --- /dev/null +++ b/apps/web/drizzle/migrations/0018_registry_publishing.sql @@ -0,0 +1,86 @@ +-- 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 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'; +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_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, + `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 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, + `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..f70d19f --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0018_snapshot.json @@ -0,0 +1,3028 @@ +{ + "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_created": { + "name": "idx_oidc_token_exchanges_created", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "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": { + "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": { + "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..7101722 --- /dev/null +++ b/apps/web/tests/releases/oidc-trusted-publishing.test.ts @@ -0,0 +1,192 @@ +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, verifyGithubOidcToken } 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.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 }); + 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"); + }); + + it("refuses unknown publishers", async () => { + await configure(TRUST); + 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..ce140de --- /dev/null +++ b/apps/web/tests/releases/release-download-url.test.ts @@ -0,0 +1,100 @@ +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 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); + }); + + 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..b964769 --- /dev/null +++ b/apps/web/tests/releases/release-idempotency.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from "vitest"; +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"; + +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("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); + 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-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 new file mode 100644 index 0000000..9b31311 --- /dev/null +++ b/apps/web/tests/releases/release-review.test.ts @@ -0,0 +1,139 @@ +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, 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 })); +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); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +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("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("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"); + 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/docs/api-reference.md b/docs/api-reference.md index 64c41b7..b2ad9f5 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,74 @@ 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; 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 + 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. 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) + +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; 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 +`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. 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`, 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 +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 +1561,131 @@ 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 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 | +|--------|-----| +| 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"?: , "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, 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). + +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..532b8fc 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,46 @@ 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`): 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>` + +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 +366,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..2288d9c 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -300,6 +300,68 @@ 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 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. 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. 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 + ``` + 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. 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). +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 + 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 +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..f5b4ffb --- /dev/null +++ b/docs/release-resolution-policy.md @@ -0,0 +1,81 @@ +# 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 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: + +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): 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. + 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 + out of every by-version and "latest" resolution. + +Every decision and edge is in `release_state_audit` and `release_reviews`, +both append-only. 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). 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 new file mode 100644 index 0000000..e7337f2 --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,192 @@ +import { Command } from 'commander'; +import { execSync } from 'node:child_process'; +import chalk from 'chalk'; +import ora from 'ora'; +import { apiRequest, ApiError } from '../lib/api-client.js'; +import { getApiKey } from '../utils/config-store.js'; + +interface RegisterResponse { + skill?: { slug: string; name: string; author: string; description: string }; + created?: boolean; + skills?: Array<{ slug: string; name: string; author: string }>; + registered?: number; + skipped?: number; +} + +/** Parse owner/repo from a git remote URL (HTTPS or SSH) */ +function parseGitRemoteUrl(url: string): { owner: string; repo: string } | null { + // HTTPS: https://github.com/owner/repo.git + const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/); + if (httpsMatch) { + return { owner: httpsMatch[1], repo: httpsMatch[2] }; + } + + // SSH: git@github.com:owner/repo.git + const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/); + if (sshMatch) { + return { owner: sshMatch[1], repo: sshMatch[2] }; + } + + return null; +} + +/** Detect owner/repo from git remote origin */ +function detectGitRemote(): { owner: string; repo: string } | null { + try { + const url = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim(); + return parseGitRemoteUrl(url); + } catch { + return 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] }; + } + return null; +} + +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(); + if (!apiKey) { + console.error(chalk.red('\nAPI key required to publish skills.')); + console.error(chalk.dim('Set your API key with: skillx config set api-key ')); + console.error(chalk.dim('Get an API key at: https://skillx.sh/settings')); + process.exit(1); + } + + // Resolve owner/repo + let remote: { owner: string; repo: string } | null = null; + + if (repoArg) { + remote = parseRepoArg(repoArg); + if (!remote) { + console.error(chalk.red(`\nInvalid repo format: "${repoArg}". Expected: owner/repo`)); + process.exit(1); + } + } else { + remote = detectGitRemote(); + if (!remote) { + console.error(chalk.red('\nCould not detect GitHub repo from git remote.')); + console.error(chalk.dim('Specify explicitly: skillx import owner/repo')); + process.exit(1); + } + console.log(chalk.dim(`Detected repo: ${remote.owner}/${remote.repo}`)); + } + + // Build request body + const body: Record = { + owner: remote.owner, + repo: remote.repo, + }; + + if (options.path) { + body.skill_path = options.path; + } else if (options.scan) { + body.scan = true; + } + + // Dry run — just show what would happen + if (options.dryRun) { + console.log(chalk.bold('\nDry run — would publish:')); + console.log(` Repository: ${chalk.cyan(`${remote.owner}/${remote.repo}`)}`); + if (options.path) { + console.log(` Skill path: ${chalk.cyan(options.path)}`); + } else if (options.scan) { + console.log(` Mode: ${chalk.cyan('Scan all SKILL.md files')}`); + } else { + console.log(` Mode: ${chalk.cyan('Auto-detect (root skill or scan)')}`); + } + console.log(chalk.dim('\nRemove --dry-run to publish.')); + return; + } + + // Call register API + const spinner = ora('Publishing to SkillX...').start(); + + try { + const res = await apiRequest('/api/skills/register', { + method: 'POST', + body: JSON.stringify(body), + }); + spinner.stop(); + displayResult(res, remote.owner, remote.repo); + } catch (err) { + spinner.stop(); + throw err; + } + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 401) { + console.error(chalk.red('\nAuthentication failed. Check your API key.')); + console.error(chalk.dim('Update with: skillx config set api-key ')); + } else if (error.status === 403) { + console.error(chalk.red('\nPermission denied. You must have access to this GitHub repo.')); + console.error(chalk.dim('Make sure your GitHub account is a collaborator or owner of the repo.')); + } else if (error.status === 404) { + console.error(chalk.red('\nNo SKILL.md found in the repository.')); + console.error(chalk.dim('Create a SKILL.md file in your repo and try again.')); + } else { + console.error(chalk.red(`\nAPI Error: ${error.message}`)); + } + } else if (error instanceof Error) { + console.error(chalk.red(`\nError: ${error.message}`)); + } else { + console.error(chalk.red('\nAn unexpected error occurred')); + } + 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 + if (res.skill) { + if (res.created) { + console.log(chalk.bold.green(`\nPublished: ${res.skill.name}`)); + } else { + console.log(chalk.bold.yellow(`\nAlready exists: ${res.skill.name}`)); + } + console.log(` Slug: ${chalk.cyan(res.skill.slug)}`); + console.log(` Author: ${chalk.dim(res.skill.author)}`); + if (res.skill.description) { + console.log(` Description: ${chalk.dim(res.skill.description.substring(0, 80))}`); + } + console.log(chalk.dim(`\nView at: ${chalk.underline(`https://skillx.sh/skills/${res.skill.slug}`)}`)); + return; + } + + // Multi-skill scan result + if (res.skills && res.skills.length > 0) { + console.log(chalk.bold.green(`\nPublished ${res.skills.length} skill(s) from ${owner}/${repo}:\n`)); + for (const s of res.skills) { + console.log(` ${chalk.cyan(s.slug)} — ${s.name} by ${chalk.dim(s.author)}`); + } + if (res.registered || res.skipped) { + console.log(chalk.dim(`\n ${res.registered ?? 0} new, ${res.skipped ?? 0} already existed`)); + } + return; + } + + console.log(chalk.yellow(`\nNo skills found in ${owner}/${repo}`)); +} 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 index 2027729..2355b4b 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,182 +1,46 @@ import { Command } from 'commander'; -import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import chalk from 'chalk'; -import ora from 'ora'; -import { apiRequest, ApiError } from '../lib/api-client.js'; -import { getApiKey } from '../utils/config-store.js'; - -interface RegisterResponse { - skill?: { slug: string; name: string; author: string; description: string }; - created?: boolean; - skills?: Array<{ slug: string; name: string; author: string }>; - registered?: number; - skipped?: number; -} - -/** Parse owner/repo from a git remote URL (HTTPS or SSH) */ -function parseGitRemoteUrl(url: string): { owner: string; repo: string } | null { - // HTTPS: https://github.com/owner/repo.git - const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/); - if (httpsMatch) { - return { owner: httpsMatch[1], repo: httpsMatch[2] }; - } - - // SSH: git@github.com:owner/repo.git - const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/); - if (sshMatch) { - return { owner: sshMatch[1], repo: sshMatch[2] }; - } - - return null; -} - -/** Detect owner/repo from git remote origin */ -function detectGitRemote(): { owner: string; repo: string } | null { - try { - const url = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim(); - return parseGitRemoteUrl(url); - } catch { - return null; - } +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'; } -/** Parse explicit "owner/repo" argument */ -function parseRepoArg(arg: string): { owner: string; repo: string } | null { - const parts = arg.split('/'); - if (parts.length === 2 && parts[0] && parts[1]) { - return { owner: parts[0], repo: parts[1] }; - } - return null; +interface PublishOptions extends ReleasePublishOptions { + scan?: boolean; + dryRun?: boolean; } -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 }) => { - try { - // Require API key - const apiKey = getApiKey(); - if (!apiKey) { - console.error(chalk.red('\nAPI key required to publish skills.')); - console.error(chalk.dim('Set your API key with: skillx config set api-key ')); - console.error(chalk.dim('Get an API key at: https://skillx.sh/settings')); - process.exit(1); - } - - // Resolve owner/repo - let remote: { owner: string; repo: string } | null = null; - - if (repoArg) { - remote = parseRepoArg(repoArg); - if (!remote) { - console.error(chalk.red(`\nInvalid repo format: "${repoArg}". Expected: owner/repo`)); - process.exit(1); - } - } else { - 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')); - process.exit(1); - } - console.log(chalk.dim(`Detected repo: ${remote.owner}/${remote.repo}`)); - } - - // Build request body - const body: Record = { - owner: remote.owner, - repo: remote.repo, - }; - - if (options.path) { - body.skill_path = options.path; - } else if (options.scan) { - body.scan = true; - } - - // Dry run — just show what would happen - if (options.dryRun) { - console.log(chalk.bold('\nDry run — would publish:')); - console.log(` Repository: ${chalk.cyan(`${remote.owner}/${remote.repo}`)}`); - if (options.path) { - console.log(` Skill path: ${chalk.cyan(options.path)}`); - } else if (options.scan) { - console.log(` Mode: ${chalk.cyan('Scan all SKILL.md files')}`); - } else { - console.log(` Mode: ${chalk.cyan('Auto-detect (root skill or scan)')}`); - } - console.log(chalk.dim('\nRemove --dry-run to publish.')); - return; - } - - // Call register API - const spinner = ora('Publishing to SkillX...').start(); - - try { - const res = await apiRequest('/api/skills/register', { - method: 'POST', - body: JSON.stringify(body), - }); - spinner.stop(); - displayResult(res, remote.owner, remote.repo); - } catch (err) { - spinner.stop(); - throw err; - } - } catch (error) { - if (error instanceof ApiError) { - if (error.status === 401) { - console.error(chalk.red('\nAuthentication failed. Check your API key.')); - console.error(chalk.dim('Update with: skillx config set api-key ')); - } else if (error.status === 403) { - console.error(chalk.red('\nPermission denied. You must have access to this GitHub repo.')); - console.error(chalk.dim('Make sure your GitHub account is a collaborator or owner of the repo.')); - } else if (error.status === 404) { - console.error(chalk.red('\nNo SKILL.md found in the repository.')); - console.error(chalk.dim('Create a SKILL.md file in your repo and try again.')); - } else { - console.error(chalk.red(`\nAPI Error: ${error.message}`)); - } - } else if (error instanceof Error) { - console.error(chalk.red(`\nError: ${error.message}`)); - } else { - console.error(chalk.red('\nAn unexpected error occurred')); - } - process.exit(1); - } - }); - -function displayResult(res: RegisterResponse, owner: string, repo: string): void { - // Single skill mode - if (res.skill) { - if (res.created) { - console.log(chalk.bold.green(`\nPublished: ${res.skill.name}`)); - } else { - console.log(chalk.bold.yellow(`\nAlready exists: ${res.skill.name}`)); - } - console.log(` Slug: ${chalk.cyan(res.skill.slug)}`); - console.log(` Author: ${chalk.dim(res.skill.author)}`); - if (res.skill.description) { - console.log(` Description: ${chalk.dim(res.skill.description.substring(0, 80))}`); - } - console.log(chalk.dim(`\nView at: ${chalk.underline(`https://skillx.sh/skills/${res.skill.slug}`)}`)); - return; - } - - // Multi-skill scan result - if (res.skills && res.skills.length > 0) { - console.log(chalk.bold.green(`\nPublished ${res.skills.length} skill(s) from ${owner}/${repo}:\n`)); - for (const s of res.skills) { - console.log(` ${chalk.cyan(s.slug)} — ${s.name} by ${chalk.dim(s.author)}`); - } - if (res.registered || res.skipped) { - console.log(chalk.dim(`\n ${res.registered ?? 0} new, ${res.skipped ?? 0} already existed`)); - } +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; } - - console.log(chalk.yellow(`\nNo skills found in ${owner}/${repo}`)); -} + 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; +}