diff --git a/apps/web/app/lib/db/registry-schema.ts b/apps/web/app/lib/db/registry-schema.ts index 38e52eb..a13bfd1 100644 --- a/apps/web/app/lib/db/registry-schema.ts +++ b/apps/web/app/lib/db/registry-schema.ts @@ -8,6 +8,7 @@ export const publishers = sqliteTable( handle: text("handle").notNull().unique(), display_name: text("display_name").notNull(), owner_user_id: text("owner_user_id"), + state: text("state").notNull().default("active"), // active | suspended | revoked created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [index("idx_publishers_owner").on(table.owner_user_id)] @@ -24,6 +25,7 @@ export const packages = sqliteTable( name: text("name").notNull(), // fully-qualified '@handle/slug' slug: text("slug").notNull(), kind: text("kind").notNull(), // skill | hook-pack | bundle + visibility: text("visibility").notNull().default("public"), // public | private created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, @@ -48,15 +50,25 @@ export const packageReleases = sqliteTable( artifact_key: text("artifact_key"), // R2 object key, populated by Phase 2 artifact_digest: text("artifact_digest"), // sha256:<64 hex> artifact_size: integer("artifact_size"), + release_digest: text("release_digest"), // treeDigest of the file tree: sha256:<64 hex> + file_count: integer("file_count"), + tree_size: integer("tree_size"), + signed_manifest_key: text("signed_manifest_key"), // R2 key of the signed skillx.release/v1 manifest source_provenance: text("source_provenance"), // JSON: repo, ref, commit compatibility_json: text("compatibility_json"), // declared `compatible` map as JSON created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), published_at: integer("published_at", { mode: "timestamp_ms" }), yanked_at: integer("yanked_at", { mode: "timestamp_ms" }), + quarantined_at: integer("quarantined_at", { mode: "timestamp_ms" }), + // Last signed skillx.release-status/v1 document: strictly increasing sequence and its issuedAt. + status_sequence: integer("status_sequence"), + status_issued_at: integer("status_issued_at", { mode: "timestamp_ms" }), }, (table) => [ uniqueIndex("idx_package_releases_version").on(table.package_id, table.version), index("idx_package_releases_state").on(table.release_state), + // Not unique: one tree may be released under several versions or packages. + index("idx_package_releases_digest").on(table.release_digest), ] ); diff --git a/apps/web/drizzle/migrations/0011_release_tree_digest.sql b/apps/web/drizzle/migrations/0011_release_tree_digest.sql new file mode 100644 index 0000000..ca7d11d --- /dev/null +++ b/apps/web/drizzle/migrations/0011_release_tree_digest.sql @@ -0,0 +1,40 @@ +-- Release tree digest, signed manifest pointer, publisher state and package visibility (issue #54) +-- +-- Strictly additive: nullable columns, or NOT NULL columns with a default, plus one index. +-- Existing rows keep every value; `publishers.state` becomes 'active' and +-- `packages.visibility` becomes 'public'. `release_digest` is the +-- agentkit.skill-tree/v1 tree digest named by the signed skillx.release/v1 manifest. +-- `status_sequence` and `status_issued_at` record the last signed skillx.release-status/v1 +-- document, whose sequence must strictly increase. +-- +-- NOTE ON DRIFT: `drizzle-kit generate` emitted CREATE TABLE for all seven registry tables +-- (publishers, packages, package_releases, release_verification_evidence, entitlements, +-- collections, collection_revisions), because the 0010 snapshot omits them although 0009 +-- created them. Those statements were removed here; applying them would fail with "table +-- already exists". The 0011 snapshot records the full schema, which realigns the generator. +-- +-- Rollback (SQLite/D1 support DROP COLUMN since 3.35; take a D1 time-travel bookmark first). +-- The last statement removes the wrangler bookkeeping row, so a later `migrations apply` +-- re-applies 0011 instead of skipping it: +-- DROP INDEX IF EXISTS idx_package_releases_digest; +-- ALTER TABLE package_releases DROP COLUMN status_issued_at; +-- ALTER TABLE package_releases DROP COLUMN status_sequence; +-- ALTER TABLE package_releases DROP COLUMN quarantined_at; +-- ALTER TABLE package_releases DROP COLUMN signed_manifest_key; +-- ALTER TABLE package_releases DROP COLUMN tree_size; +-- ALTER TABLE package_releases DROP COLUMN file_count; +-- ALTER TABLE package_releases DROP COLUMN release_digest; +-- ALTER TABLE packages DROP COLUMN visibility; +-- ALTER TABLE publishers DROP COLUMN state; +-- DELETE FROM d1_migrations WHERE name = '0011_release_tree_digest.sql'; + +ALTER TABLE `publishers` ADD `state` text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE `packages` ADD `visibility` text DEFAULT 'public' NOT NULL;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `release_digest` text;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `file_count` integer;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `tree_size` integer;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `signed_manifest_key` text;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `quarantined_at` integer;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `status_sequence` integer;--> statement-breakpoint +ALTER TABLE `package_releases` ADD `status_issued_at` integer;--> statement-breakpoint +CREATE INDEX `idx_package_releases_digest` ON `package_releases` (`release_digest`); diff --git a/apps/web/drizzle/migrations/meta/0011_snapshot.json b/apps/web/drizzle/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..09b8d84 --- /dev/null +++ b/apps/web/drizzle/migrations/meta/0011_snapshot.json @@ -0,0 +1,2004 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "748d3fcf-9d3f-46a6-8c0b-324ee78d35c3", + "prevId": "22aa723f-5acc-43d2-8f0a-7455bd811d41", + "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 + } + }, + "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 + }, + "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 + } + }, + "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_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": {} + }, + "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": {} + } + }, + "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 d37d4cb..19e812e 100644 --- a/apps/web/drizzle/migrations/meta/_journal.json +++ b/apps/web/drizzle/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1789982954304, "tag": "0010_sour_runaways", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1791866720909, + "tag": "0011_release_tree_digest", + "breakpoints": true } ] } diff --git a/apps/web/tests/db/release-tree-digest-migration.test.ts b/apps/web/tests/db/release-tree-digest-migration.test.ts new file mode 100644 index 0000000..b4fb954 --- /dev/null +++ b/apps/web/tests/db/release-tree-digest-migration.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { DatabaseSync } from "node:sqlite"; +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +/** + * Migration 0011 (release tree digest, publisher state, package visibility) must be strictly + * additive: it applies to a fresh database and to a database at 0010 with rows, leaves every + * existing value unchanged, and its recorded rollback SQL restores the 0010 shape. + */ +const MIGRATIONS_DIR = fileURLToPath(new URL("../../drizzle/migrations", import.meta.url)); +const MIGRATION_0011 = "0011_release_tree_digest.sql"; + +const migrationFiles = readdirSync(MIGRATIONS_DIR) + .filter((file) => file.endsWith(".sql")) + .sort(); +/** Every migration up to and including 0011, selected by name so later migrations do not shift it. */ +const THROUGH_0011 = migrationFiles.filter((file) => file <= MIGRATION_0011); +/** The database shape 0011 applies to. */ +const BEFORE_0011 = migrationFiles.filter((file) => file < MIGRATION_0011); + +function statementsOf(file: string): string[] { + return readFileSync(join(MIGRATIONS_DIR, file), "utf-8") + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter((statement) => statement !== ""); +} + +function applyMigrations(raw: DatabaseSync, files: string[]) { + for (const file of files) for (const statement of statementsOf(file)) raw.exec(statement); +} + +/** Statements with their comment lines removed. */ +function executableSql(file: string): string[] { + return statementsOf(file) + .map((statement) => + statement + .split("\n") + .filter((line) => !line.trim().startsWith("--")) + .join("\n") + .trim(), + ) + .filter((statement) => statement !== ""); +} + +/** The rollback statements recorded in the migration header comment. */ +function rollbackSql(file: string): string[] { + const lines = readFileSync(join(MIGRATIONS_DIR, file), "utf-8").split("\n"); + const start = lines.findIndex((line) => line.startsWith("-- Rollback")); + expect(start, "migration records rollback SQL").toBeGreaterThanOrEqual(0); + const out: string[] = []; + for (const line of lines.slice(start + 1)) { + const match = /^--\s{3}(.+;)\s*$/.exec(line); + if (match) out.push(match[1]!); + else if (!line.startsWith("--")) break; + } + return out; +} + +type Row = Record; + +function columns(raw: DatabaseSync, table: string): Row[] { + return raw.prepare(`PRAGMA table_info(${table})`).all() as Row[]; +} + +function indexes(raw: DatabaseSync, table: string): string[] { + return (raw.prepare(`PRAGMA index_list(${table})`).all() as Array<{ name: string }>).map((i) => i.name).sort(); +} + +const REGISTRY_TABLES = ["publishers", "packages", "package_releases"] as const; + +function snapshot(raw: DatabaseSync, columnsByTable: Record) { + return Object.fromEntries( + REGISTRY_TABLES.map((table) => { + const cols = columnsByTable[table]!.map((c) => `\`${c}\``).join(", "); + return [table, raw.prepare(`SELECT ${cols} FROM ${table} ORDER BY id`).all()]; + }), + ); +} + +function seedRegistry(raw: DatabaseSync) { + raw.exec(` + INSERT INTO publishers (id, handle, display_name, owner_user_id, created_at) VALUES + ('pub-1', 'publisher', 'Publisher', 'user-1', 1000), + ('pub-2', 'solo.dev', 'Solo', NULL, 2000); + INSERT INTO packages (id, publisher_id, name, slug, kind, created_at, updated_at) VALUES + ('pkg-1', 'pub-1', '@publisher/review-guardrails', 'review-guardrails', 'skill', 1000, 1100), + ('pkg-2', 'pub-2', '@solo.dev/tiny', 'tiny', 'hook-pack', 2000, 2100); + INSERT INTO package_releases (id, package_id, version, channel, release_state, manifest_schema_version, + artifact_key, artifact_digest, artifact_size, source_provenance, compatibility_json, created_at, published_at, yanked_at) VALUES + ('rel-1', 'pkg-1', '1.0.0', 'stable', 'published', 'skillx.package/v1', + 'artifacts/sha256/${"a".repeat(64)}.tar', 'sha256:${"a".repeat(64)}', 4096, '{"repo":"x"}', '{}', 1200, 1300, NULL), + ('rel-2', 'pkg-1', '1.1.0', 'beta', 'yanked', 'skillx.package/v1', NULL, NULL, NULL, NULL, NULL, 1400, NULL, 1500), + ('rel-3', 'pkg-2', '0.1.0', 'stable', 'draft', 'skillx.package/v1', NULL, NULL, NULL, NULL, NULL, 2200, NULL, NULL); + `); +} + +describe("migration 0011_release_tree_digest", () => { + it("exists and follows 0010", () => { + expect(migrationFiles).toContain(MIGRATION_0011); + expect(BEFORE_0011.at(-1)).toMatch(/^0010_/); + }); + + it("contains only additive statements", () => { + const statements = executableSql(MIGRATION_0011); + expect(statements.length).toBeGreaterThan(0); + for (const statement of statements) { + expect(statement, statement).toMatch(/^(ALTER TABLE `\w+` ADD `\w+` [^;]+;|CREATE INDEX `\w+` ON `\w+` \([^)]*\);)$/); + expect(statement).not.toMatch(/\b(DROP|DELETE|UPDATE|RENAME|INSERT)\b/i); + // A NOT NULL column added to a table with rows needs a default. + if (/NOT NULL/.test(statement)) expect(statement).toMatch(/DEFAULT '[a-z]+' NOT NULL/); + } + }); + + it("applies to a fresh database with the documented defaults", () => { + const raw = new DatabaseSync(":memory:"); + applyMigrations(raw, THROUGH_0011); + const byName = (table: string) => Object.fromEntries(columns(raw, table).map((c) => [c.name, c])); + expect(byName("publishers").state).toMatchObject({ notnull: 1, dflt_value: "'active'" }); + expect(byName("packages").visibility).toMatchObject({ notnull: 1, dflt_value: "'public'" }); + for (const column of [ + "release_digest", + "file_count", + "tree_size", + "signed_manifest_key", + "quarantined_at", + "status_sequence", + "status_issued_at", + ]) { + expect(byName("package_releases")[column], column).toMatchObject({ notnull: 0, dflt_value: null }); + } + expect(indexes(raw, "package_releases")).toContain("idx_package_releases_digest"); + expect(indexes(raw, "package_releases")).toContain("idx_package_releases_version"); + }); + + it("keeps every existing row and value when applied to a database at 0010", () => { + const raw = new DatabaseSync(":memory:"); + applyMigrations(raw, BEFORE_0011); + seedRegistry(raw); + const columnsBefore = Object.fromEntries(REGISTRY_TABLES.map((t) => [t, columns(raw, t).map((c) => String(c.name))])); + const before = snapshot(raw, columnsBefore); + + applyMigrations(raw, [MIGRATION_0011]); + + expect(snapshot(raw, columnsBefore)).toEqual(before); + for (const table of REGISTRY_TABLES) { + const after = columns(raw, table).map((c) => String(c.name)); + expect(after.slice(0, columnsBefore[table]!.length), table).toEqual(columnsBefore[table]); + } + expect(raw.prepare("SELECT id, state FROM publishers ORDER BY id").all()).toEqual([ + { id: "pub-1", state: "active" }, + { id: "pub-2", state: "active" }, + ]); + expect(raw.prepare("SELECT DISTINCT visibility FROM packages").all()).toEqual([{ visibility: "public" }]); + expect( + raw + .prepare( + "SELECT count(*) AS n FROM package_releases WHERE release_digest IS NULL AND file_count IS NULL AND tree_size IS NULL AND signed_manifest_key IS NULL AND quarantined_at IS NULL AND status_sequence IS NULL AND status_issued_at IS NULL", + ) + .get(), + ).toEqual({ n: 3 }); + }); + + it("rolls back to the 0010 shape with the recorded SQL, including wrangler's migration record", () => { + const raw = new DatabaseSync(":memory:"); + // The bookkeeping table wrangler d1 maintains next to the schema. + raw.exec( + "CREATE TABLE d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)", + ); + const record = raw.prepare("INSERT INTO d1_migrations (name) VALUES (?)"); + applyMigrations(raw, BEFORE_0011); + for (const file of BEFORE_0011) record.run(file); + seedRegistry(raw); + const shapeBefore = Object.fromEntries(REGISTRY_TABLES.map((t) => [t, [columns(raw, t), indexes(raw, t)]])); + const columnsBefore = Object.fromEntries(REGISTRY_TABLES.map((t) => [t, columns(raw, t).map((c) => String(c.name))])); + const rowsBefore = snapshot(raw, columnsBefore); + const appliedNames = () => (raw.prepare("SELECT name FROM d1_migrations ORDER BY id").all() as Array<{ name: string }>).map((r) => r.name); + + applyMigrations(raw, [MIGRATION_0011]); + record.run(MIGRATION_0011); + const rollback = rollbackSql(MIGRATION_0011); + expect(rollback.length).toBeGreaterThan(0); + for (const statement of rollback) raw.exec(statement); + + expect(Object.fromEntries(REGISTRY_TABLES.map((t) => [t, [columns(raw, t), indexes(raw, t)]]))).toEqual(shapeBefore); + expect(snapshot(raw, columnsBefore)).toEqual(rowsBefore); + expect(appliedNames()).toEqual(BEFORE_0011); + + // The rollback leaves the database ready to apply 0011 again. + applyMigrations(raw, [MIGRATION_0011]); + expect(columns(raw, "package_releases").map((c) => c.name)).toContain("release_digest"); + }); +}); diff --git a/docs/api-reference.md b/docs/api-reference.md index 91bbccf..b6c2883 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -728,6 +728,198 @@ Bulk upsert skills and index into Vectorize. For seeding demo/production data. --- +## Release Manifest Contract (`skillx.release/v1`) + +Contract only: no endpoint serves or accepts release manifests yet. The +publisher pipeline and pinned read API build on it. Source: +`packages/contracts/src/release-manifest.ts`; canonical JSON: +`packages/contracts/src/canonical-json.ts`. + +A release manifest pins one immutable `@publisher/slug@version` to the +`agentkit.skill-tree/v1` tree of its files. It is signed once and never +changes; the release lifecycle state is a separate signed document +(`skillx.release-status/v1`, below). It is a JSON object with exactly these +members; unknown members (including `state`) are rejected at every level. + +| Member | Type | Rule | +|--------|------|------| +| `schemaVersion` | string | `skillx.release/v1` | +| `identity` | string | `PACKAGE_NAME_PATTERN`, publisher and slug pass the tree segment rules; `@agentkit/*` is rejected (`identity.reserved_publisher`) | +| `version` | string | SemVer 2.0 without build metadata (`+...`) or leading zeros in numeric identifiers, at most 128 characters (`RELEASE_VERSION_PATTERN`; stricter than the package-wide `SEMVER_PATTERN`) | +| `channel` | string | `stable`, `beta`, `alpha` | +| `kind` | string | `skill`, `hook-pack`, `bundle` | +| `releaseDigest` | string | `sha256:<64 lowercase hex>`; equals the tree digest of `files` and is the release identity | +| `artifactDigest` | string | `sha256:<64 lowercase hex>` of the transport archive; integrity only, never an identity | +| `artifactSize` | integer | positive | +| `files` | array | non-empty, at most 4096 entries of `{ path, mode, sha256, size }`, sorted by raw UTF-8 path bytes, no duplicates or case-fold collisions; `path` passes the tree path, NFC and noise rules; `mode` is `100644` or `100755`; `sha256` is bare lowercase hex; `size` at most 32 MiB | +| `fileCount` | integer | equals `files.length` | +| `treeSize` | integer | sum of file sizes, at most 256 MiB | +| `publisher` | object | `{ handle, verifiedBy }`; `handle` equals the identity publisher; `verifiedBy` is `none`, `github` or `admin` (`none` means not publisher-verified). The signature is a registry key, never a publisher key, so consumers MUST NOT map these releases to trust `publisher-signed`. AgentKit will expose a verified registry signature as `registry-signed` once its skill-lifecycle contract adds that value (bestagentkits/agentkit#2277); until then the AgentKit contract defines the mapping | +| `sourceProvenance` | object or `null` | `{ repo, commit, path }`: https URL without credentials, full lowercase hex commit, package root path (`""` for the repository root). `null` when the release was not packed from a recorded git commit; consumers must label such a release "provenance unknown" | +| `declaredScripts` | array | paths from `files`, unique and sorted like `files`; informational, never executed by SkillX | +| `createdAt` | string | RFC 3339 UTC with whole seconds, e.g. `2026-10-13T04:30:00Z` | +| `keyId` | string | `^[a-z0-9][a-z0-9._-]{1,63}$` | +| `signatureAlgorithm` | string | `ed25519` | +| `signature` | string | padded standard base64 of the 64-byte Ed25519 signature: `^[A-Za-z0-9+/]{85}[AQgw]==$` (the unused trailing bits are zero, so each signature has one accepted text) | + +### Signature bytes + +The signature covers the UTF-8 bytes of the canonical JSON of the manifest +with only the `signature` member removed (so `keyId` and `signatureAlgorithm` +are signed). Canonical JSON is the RFC 8785 form restricted to this value +space: + +- object members sorted by key (UTF-16 code units; every key is ASCII), no whitespace anywhere; +- arrays keep their order; numbers are non-negative safe integers in plain decimal; +- strings escape only `"` (`\"`), `\` (`\\`), U+0008/09/0A/0C/0D (`\b \t \n \f \r`) and other + code points below U+0020 (`\u00xx`, lowercase hex); everything else, including `/`, `<`, + `>`, `&`, U+2028 and U+2029, is raw UTF-8; +- no BOM, no trailing newline. + +Go's `encoding/json.Marshal` does not produce these bytes (it HTML-escapes and +escapes U+2028/U+2029); a verifier must re-encode with the rules above. + +**Served bytes.** A served manifest or status document is exactly the +canonical JSON of the whole document, `signature` included. A verifier parses +the received bytes, re-encodes the parsed value with the rules above and +rejects any byte difference (`release.non_canonical`, `status.non_canonical`). +That one comparison rejects whitespace, member order, duplicate members +(parsers disagree on which wins; Go keeps the last), escape variants, number +forms such as `1e3` or `1.0`, a BOM, trailing bytes and invalid UTF-8, so +every verifier signs and checks the same value. In Go: decode with +`json.Decoder.UseNumber()` into `any`, re-encode, `bytes.Equal`. + +**Domain separation.** There is no prefix before the signed payload. Each +payload carries its own `schemaVersion`, each verifier requires its one schema +version and rejects unknown members, and registry release keys sign only these +two schemas, so a signature over one document type never verifies as the other. + +**Tree digest.** `releaseDigest` must equal `sha256:` plus the hex SHA-256 of +the `agentkit.skill-tree/v1` serialization of `files` +(`agentkit.skill-tree/v1\n`, then ` \n` per file in +UTF-8 path byte order). Signing refuses and verification rejects a mismatch +(`release.tree_digest_mismatch`). `@skillx/skill-tree/rules` exports the pure +`serializeTree`; `releaseTreeDigest` hashes it with WebCrypto. + +A verifier checks, in this order: canonical bytes, structure, tree digest, +signature. Validators and verifiers never throw on document input. + +Cross-language vectors: `packages/contracts/src/fixtures/release-manifest-v1.json` +(signed valid documents with their exact signing payload, tree serialization +and file contents, including paths whose UTF-8 order differs from UTF-16 +order; rejected documents with their single expected code: tampered, wrong +key, unknown key, Go-escaped, non-zero unused base64 bits in the signature +(`pattern`; a lenient decoder such as Go's `base64.StdEncoding` without +`.Strict()` accepts it, so verifiers must use the strict pattern), tree digest +mismatch, case-variant member and non-canonical byte variants; structure-only invalid-manifest cases). The +vector key is derived from a public seed text and must never be trusted +outside tests. + +### Release status (`skillx.release-status/v1`) + +The mutable state of a release is a separately signed document with the same +signature bytes rules (canonical JSON without `signature`, Ed25519, padded +base64). Source: `packages/contracts/src/release-status.ts`. + +| Member | Type | Rule | +|--------|------|------| +| `schemaVersion` | string | `skillx.release-status/v1` | +| `releaseDigest` | string | `releaseDigest` of the manifest the status describes | +| `manifestDigest` | string | `sha256:<64 lowercase hex>` of that manifest's signing payload (below); names exactly one release | +| `state` | string | `draft`, `quarantined`, `published`, `yanked` | +| `reason` | string, optional | 1 to 512 UTF-8 bytes; no `Cc`, `Cf` (including every bidi control), `Zl`, `Zp` or `Cs` code points, classified with the same pinned Unicode 15.0 tables as the tree path rules (not the runtime's Unicode data); omit the member when there is none (never `null` or `""`) | +| `issuedAt` | string | RFC 3339 UTC with whole seconds; not before the manifest's `createdAt` | +| `expiresAt` | string | RFC 3339 UTC with whole seconds; after `issuedAt` and at most 7 days later | +| `sequence` | integer | strictly increasing per `manifestDigest`, starting at 1; the status with sequence 1 is `draft` or `published` | +| `keyId` | string | `^[a-z0-9][a-z0-9._-]{1,63}$` | +| `signatureAlgorithm` | string | `ed25519` | +| `signature` | string | padded standard base64 of the 64-byte Ed25519 signature | + +**Manifest digest.** `manifestDigest` is `sha256:` followed by the lowercase +hex SHA-256 of the manifest signing payload: the UTF-8 canonical JSON of the +signed `skillx.release/v1` manifest with only `signature` removed, i.e. the +exact bytes the manifest signature covers (`releaseManifestDigest`). It covers +`keyId` and `signatureAlgorithm` but not the signature encoding. The same tree +(`releaseDigest`) released under another identity or version has a different +manifest digest, so a status never applies to a sibling release. + +**Freshness.** Every status expires. The registry re-signs the current status +of every release every 24 hours (a new `sequence`, same state) with +`expiresAt = issuedAt + 24h`; the 7-day limit is a hard cap for the contract, +not the issuing cadence, and it is the worst-case staleness a consumer can be +held to. A consumer may keep using an expired status only for content it +already installed; an expired status never authorizes a new resolution or +install. A status whose `issuedAt` is more than 300 seconds ahead of the +consumer's clock is `status.not_yet_valid`, so a future-dated status can +neither outlive the lifetime bound nor, once accepted, lock out correctly +timed later statuses. + +**Issuer obligations.** Every new status of a release has a higher `sequence` +and an `issuedAt` at least 1 second later than the previous status (timestamps +have whole-second resolution; an issuer that yanks within the same second as a +re-sign bumps `issuedAt` by 1 second), otherwise consumers reject it as +`status.issued_at_regression`. + +**Transitions.** Between consecutive statuses of one release: +`draft` to `draft` or `published`; `published` to `published`, `yanked` or +`quarantined`; `yanked` to `yanked` or `published`; `quarantined` to +`quarantined` or `yanked`. Nothing returns to `draft`. When sequences were +skipped, the new state must be reachable in at most the sequence gap steps. +Lifting a quarantine therefore takes two signed steps, `quarantined` to +`yanked` to `published`; this is intended, so a quarantined release is never +republished by a single status. + +A consumer verifies the manifest first, then checks the received status +document against it, in this order, and fails closed: + +1. canonical bytes, else `status.non_canonical`; +2. structure: an unknown member is `unknown_field`, a missing member + `required`; a `state` outside the list above is `status.unknown_state`, + even when correctly signed; a bad lifetime is `status.invalid_lifetime`; a + first status that is not `draft` or `published` is + `status.invalid_transition`; +3. `releaseDigest` equals the verified manifest's `releaseDigest`, else `status.digest_mismatch`; +4. `manifestDigest` equals the verified manifest's manifest digest, else `status.manifest_mismatch`; +5. `issuedAt` is not before the manifest's `createdAt`, else `status.issued_before_release`; +6. the signature (`signature.unknown_key`, `signature.invalid_key`, `signature.mismatch`); +7. `issuedAt` is at most 300 seconds after `now`, else `status.not_yet_valid`; + `now` is not after `expiresAt`, else `status.expired`; +8. against the last status it accepted for the same `manifestDigest`: a lower + `sequence` is `status.rollback`; the same `sequence` with different signed + bytes is `status.conflict` (identical bytes are accepted); a higher + `sequence` needs a later `issuedAt` (`status.issued_at_regression`) and an + allowed transition (`status.invalid_transition`). + +Storage (pinned read API work): the registry stores and serves the full signed +status document as issued, so `reason` needs no database column; D1 keeps only +the state, sequence and issue time used to issue the next status. + +Cross-language vectors: `packages/contracts/src/fixtures/release-status-v1.json` +(the signed manifest documents with their manifest digests, including one tree +under two versions; each vector names its manifest, the consumer's `now`, the +previously accepted status document and the received document; valid +statuses with their signing payload, including a daily re-sign, a +re-publication, a transition across a skipped sequence, a check exactly at +`expiresAt` and an `issuedAt` exactly 300 seconds ahead of `now`; invalid +documents with their single expected code, covering every `status.*` code, +tampering, wrong and unknown keys, Go escaping, a signature with non-zero +unused base64 bits, non-canonical bytes, a case-variant member, injected empty +or `null` reasons and reason rules). Same public test key as the manifest vectors. + +### Database columns (migration `0011_release_tree_digest`) + +`package_releases`: `release_digest`, `file_count`, `tree_size`, +`signed_manifest_key` (R2 key of the signed manifest), `quarantined_at`, +`status_sequence` and `status_issued_at` (sequence and issue time of the last +signed status; the state itself stays in `release_state`), and the non-unique +index `idx_package_releases_digest`. `packages.visibility` +(`public` default, `private`). `publishers.state` (`active` default, +`suspended`, `revoked`). All additive; rollback SQL is in the migration header +and ends by deleting the `0011_release_tree_digest.sql` row from +`d1_migrations`, so the migration can be applied again. + +--- + ## Error Format All error responses follow a consistent shape: diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 560b5c2..b037eac 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -11,6 +11,9 @@ "scripts": { "typecheck": "tsc --noEmit" }, + "dependencies": { + "@skillx/skill-tree": "workspace:*" + }, "devDependencies": { "typescript": "^5.9.2" } diff --git a/packages/contracts/src/canonical-json.test.ts b/packages/contracts/src/canonical-json.test.ts new file mode 100644 index 0000000..2898447 --- /dev/null +++ b/packages/contracts/src/canonical-json.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { CanonicalJsonError, canonicalJson, canonicalJsonBytes, parseCanonicalJson } from "./canonical-json"; + +// Built from code points so no editor or formatter can normalize them away. +const E_ACUTE = String.fromCodePoint(0xe9); +const EMOJI = String.fromCodePoint(0x1f600); +const HALFWIDTH_STOP = String.fromCodePoint(0xff61); +const LINE_SEPARATOR = String.fromCodePoint(0x2028); +const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029); +const DEL = String.fromCharCode(0x7f); +const LONE_HIGH = String.fromCharCode(0xd800); +const LONE_LOW = String.fromCharCode(0xdc00); + +describe("canonical JSON", () => { + it("sorts object members at every depth and drops whitespace", () => { + const a = { b: 1, a: [{ z: true, y: null }], c: { e: "x", d: 0 } }; + const b = { c: { d: 0, e: "x" }, a: [{ y: null, z: true }], b: 1 }; + expect(canonicalJson(a)).toBe('{"a":[{"y":null,"z":true}],"b":1,"c":{"d":0,"e":"x"}}'); + expect(canonicalJson(b)).toBe(canonicalJson(a)); + }); + + it("keeps array order", () => { + expect(canonicalJson(["b", "a"])).toBe('["b","a"]'); + }); + + it("orders keys by UTF-16 code units, as RFC 8785 does", () => { + const value = { [E_ACUTE]: 1, z: 2, A: 3, [EMOJI]: 4, [HALFWIDTH_STOP]: 5 }; + expect(canonicalJson(value)).toBe(`{"A":3,"z":2,"${E_ACUTE}":1,"${EMOJI}":4,"${HALFWIDTH_STOP}":5}`); + }); + + it("escapes only quote, backslash and C0 controls", () => { + const value = ['"', "\\", "\b", "\t", "\n", "\f", "\r", String.fromCharCode(0), String.fromCharCode(0x1f)].join(""); + expect(canonicalJson(value)).toBe(String.raw`"\"\\\b\t\n\f\r\u0000\u001f"`); + }); + + it("emits HTML characters, DEL, U+2028, U+2029 and non-ASCII as raw UTF-8", () => { + const value = `&${DEL}${LINE_SEPARATOR}${PARAGRAPH_SEPARATOR}caf${E_ACUTE}${EMOJI}/`; + expect(canonicalJson(value)).toBe(`"${value}"`); + expect([...canonicalJsonBytes(LINE_SEPARATOR)]).toEqual([0x22, 0xe2, 0x80, 0xa8, 0x22]); + }); + + it("writes non-negative safe integers in plain decimal", () => { + expect(canonicalJson([0, 1, Number.MAX_SAFE_INTEGER])).toBe("[0,1,9007199254740991]"); + }); + + it.each([ + ["a fraction", 1.5], + ["a negative number", -1], + ["negative zero", -0], + ["an unsafe integer", 2 ** 53], + ["NaN", Number.NaN], + ["infinity", Number.POSITIVE_INFINITY], + ["a lone high surrogate", LONE_HIGH], + ["a lone low surrogate", `x${LONE_LOW}`], + ["an undefined member", { a: undefined }], + ["a Date", new Date(0)], + ["a Map", new Map()], + ["a bigint", 1n], + // eslint-disable-next-line no-sparse-arrays + ["a sparse array", [1, , 3]], + ["an array with a trailing hole", new Array(2)], + ["a null-prototype object", Object.create(null)], + ])("rejects %s", (_name, value) => { + expect(() => canonicalJson(value)).toThrow(CanonicalJsonError); + }); + + it("names the offending path", () => { + expect(() => canonicalJson({ files: [{ size: 1.5 }] })).toThrow("files[0].size"); + // eslint-disable-next-line no-sparse-arrays + expect(() => canonicalJson({ files: [1, , 3] })).toThrow("files[1]"); + }); +}); + +describe("parseCanonicalJson", () => { + const canonical = '{"a":[1,"x"],"b":null,"c":true}'; + + it("accepts exactly the canonical text, as a string or UTF-8 bytes", () => { + expect(parseCanonicalJson(canonical)).toEqual({ ok: true, value: { a: [1, "x"], b: null, c: true } }); + expect(parseCanonicalJson(new TextEncoder().encode(canonical)).ok).toBe(true); + const unicode = `{"p":"caf${String.fromCodePoint(0xe9)} ${String.fromCodePoint(0x2028)} & <"}`; + expect(parseCanonicalJson(new TextEncoder().encode(unicode)).ok).toBe(true); + }); + + it.each<[string, string | Uint8Array]>([ + ["whitespace", '{"a": [1,"x"],"b":null,"c":true}'], + ["member order", '{"b":null,"a":[1,"x"],"c":true}'], + ["a duplicate member", '{"a":[1,"x"],"a":[1,"x"],"b":null,"c":true}'], + ["a duplicate member whose last value wins", '{"a":[2],"a":[1,"x"],"b":null,"c":true}'], + ["an exponent", '{"a":[1e0,"x"],"b":null,"c":true}'], + ["a fraction", '{"a":[1.0,"x"],"b":null,"c":true}'], + ["a negative number", '{"a":[-1,"x"],"b":null,"c":true}'], + ["an escaped ASCII character", '{"a":[1,"\\u0078"],"b":null,"c":true}'], + ["an escaped solidus", '{"a":[1,"\\/"],"b":null,"c":true}'], + ["a byte order mark", `${String.fromCodePoint(0xfeff)}${canonical}`], + ["a trailing newline", `${canonical}\n`], + ["invalid UTF-8", new Uint8Array([0x22, 0xff, 0x22])], + ["a UTF-8 byte order mark", new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(canonical)])], + ["not JSON", "{"], + ["an escaped lone surrogate", '"\\ud800"'], + ])("rejects %s without throwing", (_name, input) => { + const result = parseCanonicalJson(input); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/contracts/src/canonical-json.ts b/packages/contracts/src/canonical-json.ts new file mode 100644 index 0000000..a96d4cf --- /dev/null +++ b/packages/contracts/src/canonical-json.ts @@ -0,0 +1,141 @@ +/** + * Canonical JSON used for signed SkillX payloads. + * + * The output is the RFC 8785 (JCS) form restricted to the value space signed + * payloads use, so every byte is defined without a floating-point formatter: + * + * - objects: members sorted by key, comparing UTF-16 code units (equal to + * byte order for the ASCII keys every SkillX schema uses); no whitespace; + * members whose value is `undefined` are rejected, never skipped; + * - arrays: elements in order, no whitespace; + * - numbers: non-negative safe integers (0 .. 2^53-1) only, in plain decimal; + * - strings: `"` and `\` are escaped as `\"` and `\\`; U+0008, U+0009, + * U+000A, U+000C and U+000D as `\b`, `\t`, `\n`, `\f`, `\r`; every other + * code point below U+0020 as `\u00xx` with lowercase hex; every other code + * point, including `/`, `<`, `>`, `&`, U+007F, U+2028 and U+2029, is + * emitted as raw UTF-8. A string with an unpaired surrogate is rejected; + * - `true`, `false` and `null` literally. + * + * The bytes are the UTF-8 encoding of the result, without a BOM or trailing + * newline. Go's `encoding/json.Marshal` does NOT produce this form (it + * HTML-escapes `<>&`, escapes U+2028/U+2029 and keeps struct field order). + */ + +export class CanonicalJsonError extends Error { + constructor(path: string, message: string) { + super(path === "" ? message : `${path}: ${message}`); + this.name = "CanonicalJsonError"; + } +} + +const SHORT_ESCAPES: Readonly> = { + 0x08: "\\b", + 0x09: "\\t", + 0x0a: "\\n", + 0x0c: "\\f", + 0x0d: "\\r", + 0x22: '\\"', + 0x5c: "\\\\", +}; + +function encodeString(value: string, path: string): string { + let out = '"'; + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(i + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new CanonicalJsonError(path, "string holds an unpaired surrogate"); + out += value[i]! + value[i + 1]!; + i++; + continue; + } + if (unit >= 0xdc00 && unit <= 0xdfff) throw new CanonicalJsonError(path, "string holds an unpaired surrogate"); + const short = SHORT_ESCAPES[unit]; + if (short !== undefined) out += short; + else if (unit < 0x20) out += `\\u${unit.toString(16).padStart(4, "0")}`; + else out += value[i]!; + } + return `${out}"`; +} + +function encode(value: unknown, path: string): string { + if (value === null) return "null"; + if (value === true) return "true"; + if (value === false) return "false"; + if (typeof value === "string") return encodeString(value, path); + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) { + throw new CanonicalJsonError(path, `number ${value} is not a non-negative safe integer`); + } + return String(value); + } + if (Array.isArray(value)) { + const items: string[] = []; + // An index loop, unlike `map`, sees the holes of a sparse array. + for (let i = 0; i < value.length; i++) { + if (!(i in value)) throw new CanonicalJsonError(`${path}[${i}]`, "sparse array hole"); + items.push(encode(value[i], `${path}[${i}]`)); + } + return `[${items.join(",")}]`; + } + if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { + const record = value as Record; + // Array.prototype.sort without a comparator orders by UTF-16 code units, as JCS requires. + const keys = Object.keys(record).sort(); + const members = keys.map((key) => { + const memberPath = path === "" ? key : `${path}.${key}`; + if (record[key] === undefined) throw new CanonicalJsonError(memberPath, "value is undefined"); + return `${encodeString(key, memberPath)}:${encode(record[key], memberPath)}`; + }); + return `{${members.join(",")}}`; + } + throw new CanonicalJsonError(path, `unsupported value of type ${typeof value}`); +} + +/** The canonical JSON text of `value`. Throws `CanonicalJsonError` outside the supported value space. */ +export function canonicalJson(value: unknown): string { + return encode(value, ""); +} + +/** The UTF-8 bytes of `canonicalJson(value)`. */ +export function canonicalJsonBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalJson(value)); +} + +export type CanonicalParseResult = { ok: true; value: unknown } | { ok: false; reason: string }; + +/** + * Parses a received signed document and accepts it only when its bytes are + * exactly `canonicalJson` of the parsed value. This one comparison rejects + * whitespace, member-order and escape variants, duplicate members (parsers + * disagree on which wins), number forms such as `1e3`, `1.0` or `-0`, a BOM + * and invalid UTF-8, so every verifier sees the same value. Never throws. + */ +export function parseCanonicalJson(input: string | Uint8Array): CanonicalParseResult { + let text: string; + if (typeof input === "string") { + text = input; + } else { + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input); + } catch { + return { ok: false, reason: "is not valid UTF-8" }; + } + } + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return { ok: false, reason: "is not JSON" }; + } + let canonical: string; + try { + canonical = canonicalJson(value); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + if (canonical !== text) { + return { ok: false, reason: "is not byte-for-byte the canonical JSON of its value (whitespace, member order, duplicates, escapes or number forms)" }; + } + return { ok: true, value }; +} diff --git a/packages/contracts/src/fixtures/index.ts b/packages/contracts/src/fixtures/index.ts index 05589ca..5d1c8d4 100644 --- a/packages/contracts/src/fixtures/index.ts +++ b/packages/contracts/src/fixtures/index.ts @@ -1,5 +1,10 @@ /** * The single shared validator fixture set for `skillx.package/v1` and siblings. + * + * The signed-release fixtures (`release-fixtures.ts`, + * `release-status-fixtures.ts`) are deliberately not re-exported here: this + * module is part of the package root that Worker code bundles, and those + * fixtures exist only for tests, which import them directly. */ import { diff --git a/packages/contracts/src/fixtures/release-fixtures.ts b/packages/contracts/src/fixtures/release-fixtures.ts new file mode 100644 index 0000000..1b75d2d --- /dev/null +++ b/packages/contracts/src/fixtures/release-fixtures.ts @@ -0,0 +1,230 @@ +/** + * `skillx.release/v1` manifest fixtures. + * + * File contents are part of the fixture so every consumer can recompute the + * per-file hashes and `releaseDigest` (the `agentkit.skill-tree/v1` tree + * digest) instead of trusting the literals. The signed cross-language vectors + * in `release-manifest-v1.json` are built from these manifests. + */ + +import type { SkillxReleaseManifest, UnsignedReleaseManifest } from "../release-manifest"; +import type { FixtureCase, MutableFixture } from "./types"; + +/** Key ID of the public, test-only vector key; never trusted outside tests. */ +export const RELEASE_FIXTURE_KEY_ID = "skillx-test-vector-1"; + +/** A well-formed placeholder signature for structure-only fixtures; it verifies under no key. */ +export const RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE = `${"A".repeat(86)}==`; + +/** + * A path with non-ASCII, `&` and U+2028, which the canonical JSON emits raw + * (Go's encoding/json would escape `&` and U+2028). Built from code points + * so no editor or formatter can normalize it. + */ +export const RELEASE_FIXTURE_UNICODE_PATH = `references/caf${String.fromCodePoint(0xe9)} & notes${String.fromCodePoint(0x2028)}.md`; + +/** Exact file contents of each fixture tree, keyed by path. */ +export const RELEASE_FIXTURE_CONTENTS: Readonly>>> = { + full: { + "SKILL.md": "---\nname: review-guardrails\ndescription: Review guardrails for pull requests.\n---\n# Review guardrails\n", + [RELEASE_FIXTURE_UNICODE_PATH]: "Notes with a line separator path.\n", + "scripts/check.sh": "#!/bin/sh\necho ok\n", + }, + minimal: { + "SKILL.md": "---\nname: tiny\ndescription: Tiny skill.\n---\n", + }, +}; + +/** A publisher-verified multi-file release with git provenance and a declared script. */ +export const RELEASE_MANIFEST_FIXTURE: UnsignedReleaseManifest = { + schemaVersion: "skillx.release/v1", + identity: "@publisher/review-guardrails", + version: "1.2.0", + channel: "stable", + kind: "skill", + releaseDigest: "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + artifactDigest: `sha256:${"c".repeat(64)}`, + artifactSize: 10240, + files: [ + { path: "SKILL.md", mode: "100644", sha256: "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", size: 102 }, + { + path: RELEASE_FIXTURE_UNICODE_PATH, + mode: "100644", + sha256: "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + size: 34, + }, + { path: "scripts/check.sh", mode: "100755", sha256: "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", size: 18 }, + ], + fileCount: 3, + treeSize: 154, + publisher: { handle: "publisher", verifiedBy: "github" }, + sourceProvenance: { + repo: "https://github.com/publisher/review-guardrails", + commit: "0123456789abcdef0123456789abcdef01234567", + path: "skills/review-guardrails", + }, + declaredScripts: ["scripts/check.sh"], + createdAt: "2026-10-13T04:30:00Z", + keyId: RELEASE_FIXTURE_KEY_ID, + signatureAlgorithm: "ed25519", +}; + +/** An unverified single-file prerelease without provenance ("provenance unknown"): covers `null` and empty arrays. */ +export const RELEASE_MANIFEST_MINIMAL_FIXTURE: UnsignedReleaseManifest = { + schemaVersion: "skillx.release/v1", + identity: "@solo.dev/tiny_skill", + version: "0.1.0-beta.1", + channel: "beta", + kind: "skill", + releaseDigest: "sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544", + artifactDigest: `sha256:${"d".repeat(64)}`, + artifactSize: 1536, + files: [{ path: "SKILL.md", mode: "100644", sha256: "7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d", size: 44 }], + fileCount: 1, + treeSize: 44, + publisher: { handle: "solo.dev", verifiedBy: "none" }, + sourceProvenance: null, + declaredScripts: [], + createdAt: "2026-10-13T05:00:00Z", + keyId: RELEASE_FIXTURE_KEY_ID, + signatureAlgorithm: "ed25519", +}; + +/** A deep, mutable copy of the full fixture with a placeholder signature. */ +export function signedReleaseDraft(base: UnsignedReleaseManifest = RELEASE_MANIFEST_FIXTURE): MutableFixture { + const copy = JSON.parse(JSON.stringify(base)) as SkillxReleaseManifest; + copy.signature = RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE; + return copy as unknown as MutableFixture; +} + +type Draft = MutableFixture & { + files: Array>; + publisher: Record; + sourceProvenance: Record; +}; + +function release(mutate: (draft: Draft) => void): MutableFixture { + const draft = signedReleaseDraft() as Draft; + mutate(draft); + return draft; +} + +function rejected(name: string, expectedIssueCode: string, mutate: (draft: Draft) => void): FixtureCase { + return { name, group: "release-manifest", value: release(mutate), expectedValid: false, expectedIssueCode }; +} + +/** Structure-only cases for `validateReleaseManifest`; signatures are placeholders. */ +export const RELEASE_MANIFEST_FIXTURES: FixtureCase[] = [ + { name: "valid multi-file release", group: "release-manifest", value: signedReleaseDraft(), expectedValid: true }, + { + name: "valid minimal release", + group: "release-manifest", + value: signedReleaseDraft(RELEASE_MANIFEST_MINIMAL_FIXTURE), + expectedValid: true, + }, + rejected("rejects a version with build metadata", "pattern", (d) => { + d.version = "1.2.0+build.7"; + }), + rejected("rejects a version with a leading zero", "pattern", (d) => { + d.version = "01.2.0"; + }), + rejected("rejects a numeric prerelease identifier with a leading zero", "pattern", (d) => { + d.version = "1.2.0-beta.01"; + }), + rejected("rejects an empty prerelease identifier", "pattern", (d) => { + d.version = "1.2.0-beta..1"; + }), + rejected("rejects a version longer than 128 characters", "pattern", (d) => { + d.version = `1.2.0-${"a".repeat(123)}`; + }), + rejected("rejects a non-integer number anywhere", "type", (d) => { + d.artifactSize = 1.5; + }), + rejected("rejects an unknown top-level field", "unknown_field", (d) => { + d.price = 0; + }), + rejected("rejects an unknown file field", "unknown_field", (d) => { + d.files[0]!.executable = false; + }), + rejected("rejects a missing signature", "required", (d) => { + delete d.signature; + }), + rejected("rejects an uppercase releaseDigest", "pattern", (d) => { + d.releaseDigest = String(d.releaseDigest).toUpperCase().replace("SHA256", "sha256"); + }), + rejected("rejects a bare-hex artifactDigest", "pattern", (d) => { + d.artifactDigest = "c".repeat(64); + }), + rejected("rejects a parent-directory path", "tree.unsafe_path", (d) => { + d.files[0]!.path = "../x"; + }), + rejected("rejects a duplicate path", "duplicate", (d) => { + d.files[1]!.path = "SKILL.md"; + }), + rejected("rejects unsorted files", "order", (d) => { + d.files.reverse(); + }), + rejected("rejects a case-colliding path", "tree.case_collision", (d) => { + d.files[0]!.path = "Scripts/check.sh"; + d.files.sort((a, b) => (String(a.path) < String(b.path) ? -1 : 1)); + }), + rejected("rejects a non-NFC path", "tree.non_nfc_path", (d) => { + // "e" followed by U+0301 COMBINING ACUTE ACCENT, the NFD form of U+00E9. + d.files[1]!.path = `references/cafe${String.fromCodePoint(0x301)}.md`; + }), + rejected("rejects excluded noise", "tree.unsafe_path", (d) => { + d.files[1]!.path = "references/.DS_Store"; + }), + rejected("rejects an unknown file mode", "unsupported_value", (d) => { + d.files[0]!.mode = "120000"; + }), + rejected("rejects an uppercase file hash", "pattern", (d) => { + d.files[0]!.sha256 = String(d.files[0]!.sha256).toUpperCase(); + }), + rejected("rejects a release state member, which belongs to the status document", "unknown_field", (d) => { + d.state = "published"; + }), + rejected("rejects the reserved @agentkit publisher", "identity.reserved_publisher", (d) => { + d.identity = "@agentkit/review-guardrails"; + d.publisher.handle = "agentkit"; + }), + rejected("rejects an identity with a reserved device-name slug", "identity.invalid", (d) => { + d.identity = "@publisher/con"; + }), + rejected("rejects a publisher handle that differs from the identity", "mismatch", (d) => { + d.publisher.handle = "someone-else"; + }), + rejected("rejects an unknown publisher verification", "unsupported_value", (d) => { + d.publisher.verifiedBy = "self"; + }), + rejected("rejects a wrong fileCount", "mismatch", (d) => { + d.fileCount = 4; + }), + rejected("rejects a wrong treeSize", "mismatch", (d) => { + d.treeSize = 155; + }), + rejected("rejects a declared script missing from files", "not_found", (d) => { + d.declaredScripts = ["scripts/missing.sh"]; + }), + rejected("rejects a non-https source repo", "format", (d) => { + d.sourceProvenance.repo = "http://github.com/publisher/review-guardrails"; + }), + rejected("rejects an abbreviated source commit", "pattern", (d) => { + d.sourceProvenance.commit = "0123456"; + }), + rejected("rejects a timestamp with fractional seconds", "format", (d) => { + d.createdAt = "2026-10-13T04:30:00.000Z"; + }), + rejected("rejects an impossible calendar date", "format", (d) => { + d.createdAt = "2026-02-30T00:00:00Z"; + }), + rejected("rejects a key ID outside the registry pattern", "pattern", (d) => { + d.keyId = "Key ID"; + }), + rejected("rejects another signature algorithm", "unsupported_value", (d) => { + d.signatureAlgorithm = "rsa-pss"; + }), + rejected("rejects an unpadded signature", "pattern", (d) => { + d.signature = "A".repeat(86); + }), +]; diff --git a/packages/contracts/src/fixtures/release-manifest-v1.json b/packages/contracts/src/fixtures/release-manifest-v1.json new file mode 100644 index 0000000..c03ea60 --- /dev/null +++ b/packages/contracts/src/fixtures/release-manifest-v1.json @@ -0,0 +1,2027 @@ +{ + "description": "skillx.release/v1 signed manifest vectors. The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + "canonicalization": "signature = padded base64 Ed25519 over UTF-8 canonical JSON (RFC 8785 subset: members sorted by UTF-16 code units, no whitespace, non-negative safe integers only, only quote/backslash/C0 controls escaped, everything else raw UTF-8) of the manifest without its signature member; see packages/contracts/src/canonical-json.ts", + "verifierRules": [ + "parse document and re-encode it canonically; any byte difference (whitespace, member order, duplicate member, escapes, number form, BOM, trailing bytes, invalid UTF-8) is release.non_canonical", + "validate the structure; an unknown member, including one differing from a known member only by case, is unknown_field; a signature not matching ^[A-Za-z0-9+/]{85}[AQgw]==$ (non-zero unused base64 bits included) is pattern", + "require releaseDigest == 'sha256:' + hex SHA-256 of treeSerialization (agentkit.skill-tree/v1 of files), else release.tree_digest_mismatch", + "verify the signature with the trusted key for keyId over the canonical JSON without signature: signature.unknown_key, signature.invalid_key or signature.mismatch" + ], + "testKey": { + "keyId": "skillx-test-vector-1", + "seedText": "skillx.release/v1 test vector key; public, never trusted", + "seedHex": "1ec7ec7c27d6e97182ca8a8c0e2f3f2af27f4f7174f70ba70dbfd54f6c12c575", + "publicKeyHex": "8029f998dfaf0fd73e5bdc7762280fbb0a9805af218308d2df9213c64cdfadb2", + "publicKeyBase64": "gCn5mN+vD9c+W9x3YigPuwqYBa8hgwjS35ITxkzfrbI=" + }, + "valid": [ + { + "name": "publisher-verified multi-file release", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "signingPayloadSha256": "64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc", + "treeSerialization": "agentkit.skill-tree/v1\n100644 bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11 102 SKILL.md\n100644 d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f 34 references/café & notes
.md\n100755 b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c 18 scripts/check.sh\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHJldmlldy1ndWFyZHJhaWxzCmRlc2NyaXB0aW9uOiBSZXZpZXcgZ3VhcmRyYWlscyBmb3IgcHVsbCByZXF1ZXN0cy4KLS0tCiMgUmV2aWV3IGd1YXJkcmFpbHMK", + "references/café & notes
.md": "Tm90ZXMgd2l0aCBhIGxpbmUgc2VwYXJhdG9yIHBhdGguCg==", + "scripts/check.sh": "IyEvYmluL3NoCmVjaG8gb2sK" + } + }, + { + "name": "unverified minimal prerelease with unknown provenance", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@solo.dev/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"tEUDJBptJIcz6nVFciKaixGz3bUvTe5ZAVI6cYBFtWsK75DU3bTqSfauO8Tf2Te85zfHA3IbY0FOYPviKWS/Dw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@solo.dev/tiny_skill", + "version": "0.1.0-beta.1", + "channel": "beta", + "kind": "skill", + "releaseDigest": "sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544", + "artifactDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "artifactSize": 1536, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d", + "size": 44 + } + ], + "fileCount": 1, + "treeSize": 44, + "publisher": { + "handle": "solo.dev", + "verifiedBy": "none" + }, + "sourceProvenance": null, + "declaredScripts": [], + "createdAt": "2026-10-13T05:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "tEUDJBptJIcz6nVFciKaixGz3bUvTe5ZAVI6cYBFtWsK75DU3bTqSfauO8Tf2Te85zfHA3IbY0FOYPviKWS/Dw==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@solo.dev/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v1\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "signingPayloadSha256": "5979df5f8e7c400ac422183440040742bb34274bca37c9347d100948871e3596", + "treeSerialization": "agentkit.skill-tree/v1\n100644 7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d 44 SKILL.md\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHRpbnkKZGVzY3JpcHRpb246IFRpbnkgc2tpbGwuCi0tLQo=" + } + }, + { + "name": "paths ordered by UTF-8 bytes, not UTF-16 code units", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"d96db5b1cde1b0fa4e0d0d7996aa31085ded7851a7a520771da1b49555552159\",\"size\":60},{\"mode\":\"100644\",\"path\":\"references/。.md\",\"sha256\":\"8fbbd1833dfc6c602ce9d3ea7d07b26a73688c7fca36cb0db0074df88755563a\",\"size\":33},{\"mode\":\"100644\",\"path\":\"references/😀.md\",\"sha256\":\"1a5312dbfaeaec33318a1283f61e0bf78fa9657f9f238b20773ce330986e31ec\",\"size\":20}],\"identity\":\"@solo.dev/path-order\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:b5b69211197fee79a09fa386eb57a32709d5bb07a7e099c409e81245d97a8453\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"KYrORybgBetWrvlnOQ0Rme0j4gaRRBNqVrC+KoukqrJnckNlYzAZyc0y1IrUumS9LOJBmvCRehfk7n7irufgDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":113,\"version\":\"0.2.0\"}", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@solo.dev/path-order", + "version": "0.2.0", + "channel": "beta", + "kind": "skill", + "releaseDigest": "sha256:b5b69211197fee79a09fa386eb57a32709d5bb07a7e099c409e81245d97a8453", + "artifactDigest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "artifactSize": 1536, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "d96db5b1cde1b0fa4e0d0d7996aa31085ded7851a7a520771da1b49555552159", + "size": 60 + }, + { + "path": "references/。.md", + "mode": "100644", + "sha256": "8fbbd1833dfc6c602ce9d3ea7d07b26a73688c7fca36cb0db0074df88755563a", + "size": 33 + }, + { + "path": "references/😀.md", + "mode": "100644", + "sha256": "1a5312dbfaeaec33318a1283f61e0bf78fa9657f9f238b20773ce330986e31ec", + "size": 20 + } + ], + "fileCount": 3, + "treeSize": 113, + "publisher": { + "handle": "solo.dev", + "verifiedBy": "none" + }, + "sourceProvenance": null, + "declaredScripts": [], + "createdAt": "2026-10-13T05:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "KYrORybgBetWrvlnOQ0Rme0j4gaRRBNqVrC+KoukqrJnckNlYzAZyc0y1IrUumS9LOJBmvCRehfk7n7irufgDg==" + }, + "signingPayload": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"d96db5b1cde1b0fa4e0d0d7996aa31085ded7851a7a520771da1b49555552159\",\"size\":60},{\"mode\":\"100644\",\"path\":\"references/。.md\",\"sha256\":\"8fbbd1833dfc6c602ce9d3ea7d07b26a73688c7fca36cb0db0074df88755563a\",\"size\":33},{\"mode\":\"100644\",\"path\":\"references/😀.md\",\"sha256\":\"1a5312dbfaeaec33318a1283f61e0bf78fa9657f9f238b20773ce330986e31ec\",\"size\":20}],\"identity\":\"@solo.dev/path-order\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:b5b69211197fee79a09fa386eb57a32709d5bb07a7e099c409e81245d97a8453\",\"schemaVersion\":\"skillx.release/v1\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":113,\"version\":\"0.2.0\"}", + "signingPayloadSha256": "4ae4c01ccc7f9f2d15291a1392040594397040f5fdb145b8bba6b1df1f9b2957", + "treeSerialization": "agentkit.skill-tree/v1\n100644 d96db5b1cde1b0fa4e0d0d7996aa31085ded7851a7a520771da1b49555552159 60 SKILL.md\n100644 8fbbd1833dfc6c602ce9d3ea7d07b26a73688c7fca36cb0db0074df88755563a 33 references/。.md\n100644 1a5312dbfaeaec33318a1283f61e0bf78fa9657f9f238b20773ce330986e31ec 20 references/😀.md\n", + "fileContentsBase64": { + "SKILL.md": "LS0tCm5hbWU6IHBhdGgtb3JkZXIKZGVzY3JpcHRpb246IFBhdGggb3JkZXJpbmcgdmVjdG9yLgotLS0K", + "references/。.md": "SGFsZndpZHRoIGlkZW9ncmFwaGljIGZ1bGwgc3RvcC4K", + "references/😀.md": "QXN0cmFsLXBsYW5lIGVtb2ppLgo=" + } + } + ], + "rejected": [ + { + "name": "version changed after signing", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.1\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "flipped signature byte", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eXdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "signed by another key under the trusted key ID", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"xmFvEJWdvdJjY15sAF4O2YqthxSCz5ZDz6XA0NivxR7YVKbsUEx7bIt6MfrBC+B/RBR+8TxEXwg9zI8wku4kDQ==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "unknown key ID", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-unknown-key\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.unknown_key" + }, + { + "name": "signature over an encoding/json-escaped payload", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"9dEh1PX7CiqOi6gaClemhI+a0hF5HGSok+q6zFCsVwn2a3stv8J4WARheAWzRTIHJTwKBGT39lAgKNygoPgRAQ==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "signature with non-zero unused base64 bits (same bytes for a lenient decoder)", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDh==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "pattern" + }, + { + "name": "releaseDigest is not the tree digest of files, correctly signed", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"3f37segS8H/FZ+TY7GG+T0dObWAbq/6zEHl3yJfGVzNviuHyNvikJym/MIn6jkEu4Keg9iCeDzj30lzLRaXbCA==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.tree_digest_mismatch" + }, + { + "name": "file hash changed, correctly signed", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b10\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"/pAl8KEgJ6S5epAQaq4OClDYEPlxnoIITMF5zCeQcwBUGW8bnS6X1au32CDH5cRBpS9WR7vP+ieTtfVj+Q5WDQ==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.tree_digest_mismatch" + }, + { + "name": "member differing from a known one only by case", + "document": "{\"Version\":\"1.2.0\",\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "unknown_field" + }, + { + "name": "pretty-printed", + "document": "{\n \"schemaVersion\": \"skillx.release/v1\",\n \"identity\": \"@publisher/review-guardrails\",\n \"version\": \"1.2.0\",\n \"channel\": \"stable\",\n \"kind\": \"skill\",\n \"releaseDigest\": \"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\n \"artifactDigest\": \"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\n \"artifactSize\": 10240,\n \"files\": [\n {\n \"path\": \"SKILL.md\",\n \"mode\": \"100644\",\n \"sha256\": \"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\n \"size\": 102\n },\n {\n \"path\": \"references/café & notes
.md\",\n \"mode\": \"100644\",\n \"sha256\": \"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\n \"size\": 34\n },\n {\n \"path\": \"scripts/check.sh\",\n \"mode\": \"100755\",\n \"sha256\": \"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\n \"size\": 18\n }\n ],\n \"fileCount\": 3,\n \"treeSize\": 154,\n \"publisher\": {\n \"handle\": \"publisher\",\n \"verifiedBy\": \"github\"\n },\n \"sourceProvenance\": {\n \"repo\": \"https://github.com/publisher/review-guardrails\",\n \"commit\": \"0123456789abcdef0123456789abcdef01234567\",\n \"path\": \"skills/review-guardrails\"\n },\n \"declaredScripts\": [\n \"scripts/check.sh\"\n ],\n \"createdAt\": \"2026-10-13T04:30:00Z\",\n \"keyId\": \"skillx-test-vector-1\",\n \"signatureAlgorithm\": \"ed25519\",\n \"signature\": \"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\"\n}", + "expectedCode": "release.non_canonical" + }, + { + "name": "members in insertion order", + "document": "{\"schemaVersion\":\"skillx.release/v1\",\"identity\":\"@publisher/review-guardrails\",\"version\":\"1.2.0\",\"channel\":\"stable\",\"kind\":\"skill\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"files\":[{\"path\":\"SKILL.md\",\"mode\":\"100644\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"path\":\"references/café & notes
.md\",\"mode\":\"100644\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"path\":\"scripts/check.sh\",\"mode\":\"100755\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"fileCount\":3,\"treeSize\":154,\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"sourceProvenance\":{\"repo\":\"https://github.com/publisher/review-guardrails\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\"},\"declaredScripts\":[\"scripts/check.sh\"],\"createdAt\":\"2026-10-13T04:30:00Z\",\"keyId\":\"skillx-test-vector-1\",\"signatureAlgorithm\":\"ed25519\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\"}", + "expectedCode": "release.non_canonical" + }, + { + "name": "duplicate member", + "document": "{\"version\":\"9.9.9\",\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.non_canonical" + }, + { + "name": "exponent number form", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":1.024e4,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.non_canonical" + }, + { + "name": "escaped solidus", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts\\/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.non_canonical" + }, + { + "name": "byte order mark", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "expectedCode": "release.non_canonical" + }, + { + "name": "trailing newline", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}\n", + "expectedCode": "release.non_canonical" + } + ], + "invalidManifest": [ + { + "name": "rejects a version with build metadata", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0+build.7", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a version with a leading zero", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "01.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a numeric prerelease identifier with a leading zero", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0-beta.01", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects an empty prerelease identifier", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0-beta..1", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a version longer than 128 characters", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a non-integer number anywhere", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 1.5, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "type" + }, + { + "name": "rejects an unknown top-level field", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "price": 0 + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects an unknown file field", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102, + "executable": false + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects a missing signature", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519" + }, + "expectedCode": "required" + }, + { + "name": "rejects an uppercase releaseDigest", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4C2EAC09C7BA5FC5E0D642959C7E38B65A53CA08E65B8E728D6CA5798E458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a bare-hex artifactDigest", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a parent-directory path", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "../x", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "tree.unsafe_path" + }, + { + "name": "rejects a duplicate path", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "duplicate" + }, + { + "name": "rejects unsorted files", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "order" + }, + { + "name": "rejects a case-colliding path", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "Scripts/check.sh", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "tree.case_collision" + }, + { + "name": "rejects a non-NFC path", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "tree.non_nfc_path" + }, + { + "name": "rejects excluded noise", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/.DS_Store", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "tree.unsafe_path" + }, + { + "name": "rejects an unknown file mode", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "120000", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects an uppercase file hash", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "BCFDC953C1AF6A7FDFDE9D0A99D73BFBA6A5FAD8BB81D484B3BB416461AB5B11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a release state member, which belongs to the status document", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "state": "published" + }, + "expectedCode": "unknown_field" + }, + { + "name": "rejects the reserved @agentkit publisher", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@agentkit/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "agentkit", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "identity.reserved_publisher" + }, + { + "name": "rejects an identity with a reserved device-name slug", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/con", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "identity.invalid" + }, + { + "name": "rejects a publisher handle that differs from the identity", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "someone-else", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "mismatch" + }, + { + "name": "rejects an unknown publisher verification", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "self" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects a wrong fileCount", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 4, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "mismatch" + }, + { + "name": "rejects a wrong treeSize", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 155, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "mismatch" + }, + { + "name": "rejects a declared script missing from files", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/missing.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "not_found" + }, + { + "name": "rejects a non-https source repo", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "http://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "format" + }, + { + "name": "rejects an abbreviated source commit", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects a timestamp with fractional seconds", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00.000Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "format" + }, + { + "name": "rejects an impossible calendar date", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-02-30T00:00:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "format" + }, + { + "name": "rejects a key ID outside the registry pattern", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "Key ID", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "pattern" + }, + { + "name": "rejects another signature algorithm", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "rsa-pss", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + "expectedCode": "unsupported_value" + }, + { + "name": "rejects an unpadded signature", + "manifest": { + "schemaVersion": "skillx.release/v1", + "identity": "@publisher/review-guardrails", + "version": "1.2.0", + "channel": "stable", + "kind": "skill", + "releaseDigest": "sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974", + "artifactDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "artifactSize": 10240, + "files": [ + { + "path": "SKILL.md", + "mode": "100644", + "sha256": "bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11", + "size": 102 + }, + { + "path": "references/café & notes
.md", + "mode": "100644", + "sha256": "d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f", + "size": 34 + }, + { + "path": "scripts/check.sh", + "mode": "100755", + "sha256": "b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c", + "size": 18 + } + ], + "fileCount": 3, + "treeSize": 154, + "publisher": { + "handle": "publisher", + "verifiedBy": "github" + }, + "sourceProvenance": { + "repo": "https://github.com/publisher/review-guardrails", + "commit": "0123456789abcdef0123456789abcdef01234567", + "path": "skills/review-guardrails" + }, + "declaredScripts": [ + "scripts/check.sh" + ], + "createdAt": "2026-10-13T04:30:00Z", + "keyId": "skillx-test-vector-1", + "signatureAlgorithm": "ed25519", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "expectedCode": "pattern" + } + ] +} diff --git a/packages/contracts/src/fixtures/release-status-fixtures.ts b/packages/contracts/src/fixtures/release-status-fixtures.ts new file mode 100644 index 0000000..b3229fa --- /dev/null +++ b/packages/contracts/src/fixtures/release-status-fixtures.ts @@ -0,0 +1,167 @@ +/** + * `skillx.release-status/v1` fixtures. Statuses describe the releases of + * `release-fixtures.ts`; the signed cross-language vectors in + * `release-status-v1.json` are built from them. + */ + +import type { SkillxReleaseStatus, UnsignedReleaseStatus } from "../release-status"; +import { RELEASE_FIXTURE_KEY_ID, RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE, RELEASE_MANIFEST_FIXTURE } from "./release-fixtures"; +import type { FixtureCase, MutableFixture } from "./types"; + +/** + * A reason with non-ASCII, `&`, `<` and `>`, which the canonical JSON emits + * raw. Built from code points so no editor can normalize it. + */ +export const RELEASE_STATUS_FIXTURE_REASON = `Yanked: unsafe script & caf${String.fromCodePoint(0xe9)} notes`; + +/** `releaseManifestDigest(RELEASE_MANIFEST_FIXTURE)`; recomputed by a test. */ +export const RELEASE_FIXTURE_MANIFEST_DIGEST = "sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc"; +/** `releaseManifestDigest(RELEASE_MANIFEST_MINIMAL_FIXTURE)`; recomputed by a test. */ +export const RELEASE_MINIMAL_FIXTURE_MANIFEST_DIGEST = "sha256:5979df5f8e7c400ac422183440040742bb34274bca37c9347d100948871e3596"; + +/** The first status of the full fixture release. */ +export const RELEASE_STATUS_PUBLISHED_FIXTURE: UnsignedReleaseStatus = { + schemaVersion: "skillx.release-status/v1", + releaseDigest: RELEASE_MANIFEST_FIXTURE.releaseDigest, + manifestDigest: RELEASE_FIXTURE_MANIFEST_DIGEST, + state: "published", + issuedAt: "2026-10-13T04:31:00Z", + expiresAt: "2026-10-14T04:31:00Z", + sequence: 1, + keyId: RELEASE_FIXTURE_KEY_ID, + signatureAlgorithm: "ed25519", +}; + +/** A later status of the same release, with a reason. */ +export const RELEASE_STATUS_YANKED_FIXTURE: UnsignedReleaseStatus = { + schemaVersion: "skillx.release-status/v1", + releaseDigest: RELEASE_MANIFEST_FIXTURE.releaseDigest, + manifestDigest: RELEASE_FIXTURE_MANIFEST_DIGEST, + state: "yanked", + reason: RELEASE_STATUS_FIXTURE_REASON, + issuedAt: "2026-10-14T09:00:00Z", + expiresAt: "2026-10-15T09:00:00Z", + sequence: 2, + keyId: RELEASE_FIXTURE_KEY_ID, + signatureAlgorithm: "ed25519", +}; + +function signedStatusDraft(base: UnsignedReleaseStatus): MutableFixture { + const copy = JSON.parse(JSON.stringify(base)) as SkillxReleaseStatus; + copy.signature = RELEASE_FIXTURE_PLACEHOLDER_SIGNATURE; + return copy as unknown as MutableFixture; +} + +function variant(mutate: (draft: MutableFixture) => void): MutableFixture { + const draft = signedStatusDraft(RELEASE_STATUS_YANKED_FIXTURE); + mutate(draft); + return draft; +} + +function accepted(name: string, mutate: (draft: MutableFixture) => void): FixtureCase { + return { name, group: "release-status", value: variant(mutate), expectedValid: true }; +} + +function rejected(name: string, expectedIssueCode: string, mutate: (draft: MutableFixture) => void): FixtureCase { + return { name, group: "release-status", value: variant(mutate), expectedValid: false, expectedIssueCode }; +} + +/** A reason of exactly 512 UTF-8 bytes but only 256 UTF-16 code units. */ +export const RELEASE_STATUS_MAX_REASON = String.fromCodePoint(0xe9).repeat(256); + +/** Structure-only cases for `validateReleaseStatus`; signatures are placeholders. */ +export const RELEASE_STATUS_FIXTURES: FixtureCase[] = [ + { name: "valid status without a reason", group: "release-status", value: signedStatusDraft(RELEASE_STATUS_PUBLISHED_FIXTURE), expectedValid: true }, + { name: "valid status with a reason", group: "release-status", value: signedStatusDraft(RELEASE_STATUS_YANKED_FIXTURE), expectedValid: true }, + accepted("accepts a lifetime of exactly 7 days", (d) => { + d.expiresAt = "2026-10-21T09:00:00Z"; + }), + accepted("accepts a reason of exactly 512 UTF-8 bytes", (d) => { + d.reason = RELEASE_STATUS_MAX_REASON; + }), + accepted("accepts a later status in a state no first status may have", (d) => { + d.state = "quarantined"; + }), + rejected("rejects an unknown state", "status.unknown_state", (d) => { + d.state = "revoked"; + }), + rejected("rejects a missing state", "required", (d) => { + delete d.state; + }), + rejected("rejects an unknown member", "unknown_field", (d) => { + d.ttl = 86400; + }), + rejected("rejects a missing expiresAt", "required", (d) => { + delete d.expiresAt; + }), + rejected("rejects an expiresAt before issuedAt", "status.invalid_lifetime", (d) => { + d.expiresAt = "2026-10-14T08:59:59Z"; + }), + rejected("rejects an expiresAt equal to issuedAt", "status.invalid_lifetime", (d) => { + d.expiresAt = d.issuedAt; + }), + rejected("rejects a lifetime longer than 7 days", "status.invalid_lifetime", (d) => { + d.expiresAt = "2026-10-21T09:00:01Z"; + }), + rejected("rejects a first status that is yanked", "status.invalid_transition", (d) => { + d.sequence = 1; + }), + rejected("rejects a reason over 512 UTF-8 bytes", "length", (d) => { + d.reason = `${RELEASE_STATUS_MAX_REASON}a`; + }), + rejected("rejects a bidi override in the reason", "format", (d) => { + d.reason = `safe${String.fromCodePoint(0x202e)}hs.kcehc`; + }), + rejected("rejects a line separator in the reason", "format", (d) => { + d.reason = `line one${String.fromCodePoint(0x2028)}line two`; + }), + rejected("rejects a zero-width format character in the reason", "format", (d) => { + d.reason = `yan${String.fromCodePoint(0x200b)}ked`; + }), + rejected("rejects a sparse array value", "type", (d) => { + // eslint-disable-next-line no-sparse-arrays + d.reason = [, "x"] as unknown as string; + }), + rejected("rejects a missing signature", "required", (d) => { + delete d.signature; + }), + rejected("rejects a zero sequence", "type", (d) => { + d.sequence = 0; + }), + rejected("rejects a fractional sequence", "type", (d) => { + d.sequence = 1.5; + }), + rejected("rejects a numeric-string sequence", "type", (d) => { + d.sequence = "2"; + }), + rejected("rejects an empty reason", "length", (d) => { + d.reason = ""; + }), + rejected("rejects a null reason", "type", (d) => { + d.reason = null; + }), + rejected("rejects a control character in the reason", "format", (d) => { + d.reason = `line one${String.fromCodePoint(0x0a)}line two`; + }), + rejected("rejects a bare-hex releaseDigest", "pattern", (d) => { + d.releaseDigest = "c".repeat(64); + }), + rejected("rejects a missing manifestDigest", "required", (d) => { + delete d.manifestDigest; + }), + rejected("rejects an uppercase manifestDigest", "pattern", (d) => { + d.manifestDigest = String(d.manifestDigest).toUpperCase(); + }), + rejected("rejects a timestamp with an offset", "format", (d) => { + d.issuedAt = "2026-10-14T16:00:00+07:00"; + }), + rejected("rejects the release manifest schema version", "unsupported_value", (d) => { + d.schemaVersion = "skillx.release/v1"; + }), + rejected("rejects a key ID outside the registry pattern", "pattern", (d) => { + d.keyId = "Key ID"; + }), + rejected("rejects another signature algorithm", "unsupported_value", (d) => { + d.signatureAlgorithm = "rsa-pss"; + }), +]; diff --git a/packages/contracts/src/fixtures/release-status-v1.json b/packages/contracts/src/fixtures/release-status-v1.json new file mode 100644 index 0000000..c90b0ef --- /dev/null +++ b/packages/contracts/src/fixtures/release-status-v1.json @@ -0,0 +1,414 @@ +{ + "description": "skillx.release-status/v1 signed status vectors. The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + "canonicalization": "same as skillx.release/v1: padded base64 Ed25519 over the UTF-8 canonical JSON of the status without its signature member; an absent reason is omitted, never null or empty; a served document is exactly the canonical JSON including signature", + "manifestDigestRule": "manifestDigest = 'sha256:' + lowercase hex SHA-256 of the manifest signing payload: the UTF-8 canonical JSON of the skillx.release/v1 manifest without its signature member (the exact bytes its signature covers)", + "consumerRules": [ + "verify the manifest document first (skillx.release/v1 vectors); statuses are checked against that verified manifest", + "re-encode the received document canonically; any byte difference is status.non_canonical", + "validate the structure: unknown member unknown_field; missing member required; state outside draft|quarantined|published|yanked status.unknown_state even when signed; reason 1..512 UTF-8 bytes (length, null is type) without Cc, Cf (incl. bidi controls, Unicode 15.0 tables), Zl, Zp or Cs code points (format); signature matching ^[A-Za-z0-9+/]{85}[AQgw]==$ else pattern; issuedAt < expiresAt <= issuedAt + 7 days else status.invalid_lifetime; sequence 1 must be draft or published else status.invalid_transition", + "require releaseDigest to equal the verified manifest's releaseDigest, else status.digest_mismatch", + "require manifestDigest to equal the verified manifest's manifest digest, else status.manifest_mismatch", + "require issuedAt >= the manifest createdAt, else status.issued_before_release", + "verify the signature with the trusted key for keyId: signature.unknown_key, signature.invalid_key or signature.mismatch", + "require issuedAt <= now + 300 s, else status.not_yet_valid", + "require now <= expiresAt, else status.expired; an expired status may only keep already-installed content, never authorize a new resolution or install", + "per manifestDigest, against the last accepted status: a lower sequence is status.rollback; the same sequence with different signed bytes is status.conflict (identical bytes are accepted); a higher sequence needs a later issuedAt (status.issued_at_regression) and a state reachable in at most (sequence gap) steps of draft->draft|published, published->published|yanked|quarantined, yanked->yanked|published, quarantined->quarantined|yanked (status.invalid_transition)" + ], + "testKey": { + "keyId": "skillx-test-vector-1", + "seedText": "skillx.release/v1 test vector key; public, never trusted", + "seedHex": "1ec7ec7c27d6e97182ca8a8c0e2f3f2af27f4f7174f70ba70dbfd54f6c12c575", + "publicKeyHex": "8029f998dfaf0fd73e5bdc7762280fbb0a9805af218308d2df9213c64cdfadb2", + "publicKeyBase64": "gCn5mN+vD9c+W9x3YigPuwqYBa8hgwjS35ITxkzfrbI=" + }, + "manifests": [ + { + "name": "1.2.0 of the multi-file tree", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"eHdqtSvMKJH6rIGay9PgwrQhibz6H41zziDqgnIeWMw6rAlOS3NXVTgpVLeMRo5Kn8s6o7K8xs5V/UY29WHmDg==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.0\"}", + "manifestDigest": "sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc" + }, + { + "name": "1.2.1 of the same tree", + "document": "{\"artifactDigest\":\"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"artifactSize\":10240,\"channel\":\"stable\",\"createdAt\":\"2026-10-13T04:30:00Z\",\"declaredScripts\":[\"scripts/check.sh\"],\"fileCount\":3,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"bcfdc953c1af6a7fdfde9d0a99d73bfba6a5fad8bb81d484b3bb416461ab5b11\",\"size\":102},{\"mode\":\"100644\",\"path\":\"references/café & notes
.md\",\"sha256\":\"d8bcfd787914776aba5f5923ce82d187239133d4c4fd46efd26c83aae0f0a84f\",\"size\":34},{\"mode\":\"100755\",\"path\":\"scripts/check.sh\",\"sha256\":\"b4d644d4279594903f1a9911956432d9473041f2984fc6014c14d7402c7d126c\",\"size\":18}],\"identity\":\"@publisher/review-guardrails\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"publisher\",\"verifiedBy\":\"github\"},\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"luJjP0RMcubnl6QZf3boxrHDen2iAchsvxQ19zqAFkE79xqSO2DpevYIEDGmeL5Qw0LDOrbGxuc/uqHvWBqXCw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"path\":\"skills/review-guardrails\",\"repo\":\"https://github.com/publisher/review-guardrails\"},\"treeSize\":154,\"version\":\"1.2.1\"}", + "manifestDigest": "sha256:c598df8848000fea906e7e4d60237c30d927ef4ee26e5148b9091920eb0016a9" + }, + { + "name": "minimal release", + "document": "{\"artifactDigest\":\"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"artifactSize\":1536,\"channel\":\"beta\",\"createdAt\":\"2026-10-13T05:00:00Z\",\"declaredScripts\":[],\"fileCount\":1,\"files\":[{\"mode\":\"100644\",\"path\":\"SKILL.md\",\"sha256\":\"7822f865b6a9be616262a844ffb54085d095857dcf35a172150523db31a3726d\",\"size\":44}],\"identity\":\"@solo.dev/tiny_skill\",\"keyId\":\"skillx-test-vector-1\",\"kind\":\"skill\",\"publisher\":{\"handle\":\"solo.dev\",\"verifiedBy\":\"none\"},\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release/v1\",\"signature\":\"tEUDJBptJIcz6nVFciKaixGz3bUvTe5ZAVI6cYBFtWsK75DU3bTqSfauO8Tf2Te85zfHA3IbY0FOYPviKWS/Dw==\",\"signatureAlgorithm\":\"ed25519\",\"sourceProvenance\":null,\"treeSize\":44,\"version\":\"0.1.0-beta.1\"}", + "manifestDigest": "sha256:5979df5f8e7c400ac422183440040742bb34274bca37c9347d100948871e3596" + } + ], + "valid": [ + { + "name": "first status of a release", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "530fa7fca53abb9f7f68fcf122fcc0e05ef5bd50b91afb004a2ab9604efd3583" + }, + { + "name": "yanked with a reason after the published status", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayloadSha256": "145b51b5f2e76f6ca40ebb8cd0a29ac8e64eafe092154c23cd6c12956b3388a6" + }, + { + "name": "identical re-fetch of the accepted status", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayloadSha256": "145b51b5f2e76f6ca40ebb8cd0a29ac8e64eafe092154c23cd6c12956b3388a6" + }, + { + "name": "first status of another version of the same tree, tracked separately", + "manifest": "1.2.1 of the same tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:c598df8848000fea906e7e4d60237c30d927ef4ee26e5148b9091920eb0016a9\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"DjNwD6CfPwAJtAV8fpwCM0xEp/M3Kyed6FuEY7n0YYPF08jUPsmqM8hp+x6Ab6Dt/h/X5AbemtygxbeuuTndCg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:c598df8848000fea906e7e4d60237c30d927ef4ee26e5148b9091920eb0016a9\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "4c55983622dc089390e67b3401029e339a0a4028d553df33d54427ca5093fd65" + }, + { + "name": "daily re-sign of the same state", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-15T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"DOQKzwXbj9ZDHlJ+jtJ1HfIPXtaH3jER4UlbA/ErIpatbihfh/ZH9mlRqi48dJEU41bxmfjXQCNFs7vWobaXCw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "signingPayloadSha256": "2fe57bfa8c9cf706f7a30d8d1ecfc14894212a52ec774fd31068240acf2083ec" + }, + { + "name": "yanked release published again", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-15T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"Pl9KFJ9s/uuN/iUc+kYQolwOWQGxTxI9yxTqwN/8ZERUjalg/S8p4hUX5U5/SGfhP14tDbAcNabPOaGTasHrBg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "d85d87cec8314c03433130139e2f468da3715b188c97f2911af0e047ced06eb4" + }, + { + "name": "quarantined to published across a skipped sequence (via yanked)", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-15T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"fSI1jAC48HfiOBo/Ofvfk5rc5VMuZpE1Hu7RHXPswDP+HlWz9NoUl2UkBO/3rABl/tpBpOCmyfMbXZ+uuWbdAQ==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"quarantined\"}", + "document": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":4,\"signature\":\"s+AtFBEYn5iDaNEVAZs52mjkjbomoTMzp5YcTNKOK8Sfp8iPP3sYFN9CSBNnhuOG94qTFZboIfWzKxZ8OKmcBw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":4,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "185bc4059d938d67e7d423e48aa6429428e6dcb7c459dcd613fd15e5b055142c" + }, + { + "name": "checked exactly at expiresAt", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T04:31:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "530fa7fca53abb9f7f68fcf122fcc0e05ef5bd50b91afb004a2ab9604efd3583" + }, + { + "name": "issuedAt exactly at the 300 second clock-skew limit", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T04:26:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayload": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "signingPayloadSha256": "530fa7fca53abb9f7f68fcf122fcc0e05ef5bd50b91afb004a2ab9604efd3583" + } + ], + "invalid": [ + { + "name": "state changed after signing", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "reason changed after signing", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes.\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "sequence raised after signing", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "expiry extended after signing", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-21T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "signed by another key under the trusted key ID", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"OSfUAL28TXLpmGkqHYwhmGIU77vAXkaBEkuSE5jN3C9yVFLwstTYKMw3iOMrqG3QgSikwInDU9sJAVKMTyHVDQ==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "unknown key ID", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-unknown-key\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.unknown_key" + }, + { + "name": "signature over an encoding/json-escaped payload", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"nKS2hxRzrYba8CtRTFSSO7aI27KHPkGhOAhtNcQGQCb0l1gwkj+9fXgr8cDxXNiNC+BFvxY0YcRY5ZbRZz73Aw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "signature.mismatch" + }, + { + "name": "pretty-printed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\n \"schemaVersion\": \"skillx.release-status/v1\",\n \"releaseDigest\": \"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\n \"manifestDigest\": \"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\n \"state\": \"yanked\",\n \"reason\": \"Yanked: unsafe script & café notes\",\n \"issuedAt\": \"2026-10-14T09:00:00Z\",\n \"expiresAt\": \"2026-10-15T09:00:00Z\",\n \"sequence\": 2,\n \"keyId\": \"skillx-test-vector-1\",\n \"signatureAlgorithm\": \"ed25519\",\n \"signature\": \"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\"\n}", + "expectedCode": "status.non_canonical" + }, + { + "name": "members in insertion order", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"schemaVersion\":\"skillx.release-status/v1\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"state\":\"yanked\",\"reason\":\"Yanked: unsafe script & café notes\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"expiresAt\":\"2026-10-15T09:00:00Z\",\"sequence\":2,\"keyId\":\"skillx-test-vector-1\",\"signatureAlgorithm\":\"ed25519\",\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\"}", + "expectedCode": "status.non_canonical" + }, + { + "name": "duplicate member", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"state\":\"published\",\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.non_canonical" + }, + { + "name": "exponent number form", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2e0,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.non_canonical" + }, + { + "name": "escaped ASCII character", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed2551\\u0039\",\"state\":\"yanked\"}", + "expectedCode": "status.non_canonical" + }, + { + "name": "byte order mark", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.non_canonical" + }, + { + "name": "member differing from a known one only by case, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"State\":\"published\",\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"JjrrNahUVS+3nP7o+xuYy9B2WRljDuiiyBdXyZpeEMVTac56hg8zL0faEDunMa9EW3BhtaKwJaW0S3k85h7gBA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "unknown_field" + }, + { + "name": "empty reason injected, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"DhxVFeMmfwzQ5r2fX7toj3bFGfuPek2Pa9sK0vNnxa6CJbAMtZ+uJmrqWl0gnM4ZkBxFvRA+eX2ACgyQEimsAw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "length" + }, + { + "name": "null reason injected, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":null,\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"MLOwD7Rw21+Gx+OaoyqFVdC3TK+SbVaK5a13j8PFH1bWbPDwhATJ29eji7Cq2ppvUcam47UGTUV0NAs/J3dxDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "type" + }, + { + "name": "unknown state, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"JFNEQS/elLwLb11XInYk+hX+w77CPsqEEp3LasgFctnpPo4aHKybfvWwsvWkJoOhvkCrABJNUQnBrNHMpgWbAg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"revoked\"}", + "expectedCode": "status.unknown_state" + }, + { + "name": "missing manifestDigest, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"BJtPNJpKFBxvfqtdG6Qb9iPZQ5zaL654Eko3OYSQZwhMBPo2yEAGTzuJ4sH7LMDWfivDXVrzhqOlLodUl3p9Ag==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "required" + }, + { + "name": "missing expiresAt, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"AUULhdLU6L0sCQXUi7CXFISvhwvyF27XrIlW7Z1VVdeYLOLFGaS238Njby+XFXkgHCTKvHJe62Y+ZvelnPtTBQ==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "required" + }, + { + "name": "expiresAt before issuedAt, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-13T04:30:59Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"O+gZkw8lHhtJ7zefbstOGSXJPPe4cYsPHeIrWN5h16hGotkWYGChChtaxYFBwNYPiICICDImGvadjASh/nnECg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.invalid_lifetime" + }, + { + "name": "lifetime longer than 7 days, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-20T04:31:01Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"WUlp2TnLSkmeQHFiv3Jok2AEU4bPJO+5PWjITX8SO8JCIXq9vLKThLZ+jRdF/cFfoW4JhV9wxSFYZt+0yPc7Bw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.invalid_lifetime" + }, + { + "name": "first status that is yanked, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"PWR/QEPHvJRut0Gnwjo948jiFrZFvXKyC5eNPDittTL3kXLZwXeP4QeSwYqgn++JkBHK3QEqio+Ed0Xi7pvYDw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.invalid_transition" + }, + { + "name": "reason over 512 UTF-8 bytes, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé.\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"ll+/hBkM2OBu/lGLyIc9PqRcmh6P80kVqSriBjA40iMgcUzLKm3qT1Cd/oOUo/8lgCcY0OBDOIb0qihIxkbdDQ==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "length" + }, + { + "name": "bidi override in the reason, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"see ‮hs.kcehc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"qN86bLtTH7GNNgJwmx/3SKzdbvBW8rKxDGPGbRnzy1Dj433gyBHLsQTxuGh/zc81lzwPfHqBCgzBlJCHDbRyBw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "format" + }, + { + "name": "status of another release tree", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:5979df5f8e7c400ac422183440040742bb34274bca37c9347d100948871e3596\",\"releaseDigest\":\"sha256:2b80424f6839c7ae445108317a623797ecab4ee94fbb6cddbc9695326aed8544\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"vjp2IFJqOHK+v0xHvQSHnXTZZYcjYbLxH9QQB5e4O1bfZ1OkIWCUWt0AL8pjXJ+L6Fci0DjBdhsATDth7HCxBg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.digest_mismatch" + }, + { + "name": "status of another version of the same tree", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:c598df8848000fea906e7e4d60237c30d927ef4ee26e5148b9091920eb0016a9\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"GCa4fNHeaF4H9SkeRXVZja8UWPtbQeMSLC4R8txof1gFfLY7G666mrneli0STnizR53dxfcSMs2bxnUxcUoODA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.manifest_mismatch" + }, + { + "name": "wrong manifestDigest, correctly signed", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"RZmIudxDeH0v5lTPc2EYQM4u/wQupwR2gaJF7KrNkyneWQyjCKyNMOEj1lopIuOGFSPZm0TldmHoOIq6peafCw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.manifest_mismatch" + }, + { + "name": "issued before the manifest was created", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:29:59Z\",\"issuedAt\":\"2026-10-13T04:29:59Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"9ovE7/Rbq/dIFL5Aigbagfzb9BUbLT6+gVz8R4mdSyhtwxcl7IFj+RLxkpl9K3RvfpV6yPu9TZmFBdcOYc2oCg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.issued_before_release" + }, + { + "name": "expired one second after expiresAt", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T04:31:01Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.expired" + }, + { + "name": "issuedAt 301 seconds ahead of the consumer's clock", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T04:25:59Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.not_yet_valid" + }, + { + "name": "signature with non-zero unused base64 bits (same bytes for a lenient decoder)", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": null, + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABB==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "pattern" + }, + { + "name": "lower sequence than the accepted status (rollback)", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-13T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-14T04:31:00Z\",\"issuedAt\":\"2026-10-13T04:31:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":1,\"signature\":\"R32/j4L6Tn/tlB1TV9X5EI0Yu1F82aAV8NZMEG5s0yPFqy3/T+n8nwNuVdRrGVhMCC1eBKnuPebWVNLcGQ4uDg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.rollback" + }, + { + "name": "same sequence with different content (conflict)", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"yBF2lq3npr1NOJaMNM1xinpOh9RBX/9uy4ahiJHobRkl3ZRgNzir0zkagSrVl+NnPlRILNi/II1BjvI26dcGBw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.conflict" + }, + { + "name": "higher sequence issued no later (issuedAt regression)", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-14T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"YFTjuOtuMygvouP2+R54t6NtQVSI6iAqxTvc6txDjvD5ZNzXyXCs/z6tbyV03HYR+n8lRwtHFKpPw+3UEbpiDA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "expectedCode": "status.issued_at_regression" + }, + { + "name": "yanked back to draft", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-15T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"QrSknNYk/lisL8cn+8RGzYvSqD/5bCEUmAnR3RYdGqG/cefvgXcTx/U5Eg+c+2Pk/7VVzTu4FlTahxSewOUABA==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"yanked\"}", + "document": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"gHc+lCAgq8O5/xxaceuBBSKwjUbPNr8UL9aG0I/Xful+LfLQgS2d4a4NAGuX5aXnSHBi/DrSvmBi1rPGrSUbBw==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"draft\"}", + "expectedCode": "status.invalid_transition" + }, + { + "name": "quarantined straight to published", + "manifest": "1.2.0 of the multi-file tree", + "now": "2026-10-15T12:00:00Z", + "previous": "{\"expiresAt\":\"2026-10-15T09:00:00Z\",\"issuedAt\":\"2026-10-14T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":2,\"signature\":\"fSI1jAC48HfiOBo/Ofvfk5rc5VMuZpE1Hu7RHXPswDP+HlWz9NoUl2UkBO/3rABl/tpBpOCmyfMbXZ+uuWbdAQ==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"quarantined\"}", + "document": "{\"expiresAt\":\"2026-10-16T09:00:00Z\",\"issuedAt\":\"2026-10-15T09:00:00Z\",\"keyId\":\"skillx-test-vector-1\",\"manifestDigest\":\"sha256:64de9b194abf1627ca00c09be146106c3c044637eeba2c27fb031d5b171312bc\",\"reason\":\"Yanked: unsafe script & café notes\",\"releaseDigest\":\"sha256:4c2eac09c7ba5fc5e0d642959c7e38b65a53ca08e65b8e728d6ca5798e458974\",\"schemaVersion\":\"skillx.release-status/v1\",\"sequence\":3,\"signature\":\"Pl9KFJ9s/uuN/iUc+kYQolwOWQGxTxI9yxTqwN/8ZERUjalg/S8p4hUX5U5/SGfhP14tDbAcNabPOaGTasHrBg==\",\"signatureAlgorithm\":\"ed25519\",\"state\":\"published\"}", + "expectedCode": "status.invalid_transition" + } + ] +} diff --git a/packages/contracts/src/fixtures/types.ts b/packages/contracts/src/fixtures/types.ts index e1a95aa..e8280b9 100644 --- a/packages/contracts/src/fixtures/types.ts +++ b/packages/contracts/src/fixtures/types.ts @@ -20,7 +20,9 @@ export type FixtureGroup = | "collection-member" | "entitlement" | "listing-import" - | "release-publish"; + | "release-publish" + | "release-manifest" + | "release-status"; /** A syntactically valid digest used by fixtures. */ export const FIXTURE_DIGEST = `sha256:${"a".repeat(64)}`; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b9b02ba..e5c31b7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,9 @@ export * from "./semver-range"; export * from "./verification-evidence"; export * from "./compatibility"; export * from "./package-manifest"; +export * from "./canonical-json"; +export * from "./release-manifest"; +export * from "./release-status"; export * from "./collections"; export * from "./ingestion"; export * from "./dto"; diff --git a/packages/contracts/src/package-manifest.test.ts b/packages/contracts/src/package-manifest.test.ts index 3230661..d2a4906 100644 --- a/packages/contracts/src/package-manifest.test.ts +++ b/packages/contracts/src/package-manifest.test.ts @@ -8,6 +8,11 @@ import { PACKAGE_MANIFEST_FIXTURE, VERIFICATION_EVIDENCE_FIXTURES, } from "./fixtures"; +import { RELEASE_MANIFEST_FIXTURES } from "./fixtures/release-fixtures"; +import { RELEASE_STATUS_FIXTURES } from "./fixtures/release-status-fixtures"; +import * as contracts from "./index"; +import { validateReleaseManifest } from "./release-manifest"; +import { validateReleaseStatus } from "./release-status"; import { PACKAGE_KINDS, RELEASE_STATES, @@ -28,20 +33,31 @@ const VALIDATOR_BY_GROUP: Record { valid: boolean; i entitlement: validateEntitlement, "listing-import": validateListingImport, "release-publish": validateReleasePublish, + "release-manifest": validateReleaseManifest, + "release-status": validateReleaseStatus, }; +/** Every fixture case, including the test-only signed-release fixtures the root export leaves out. */ +const EVERY_FIXTURE = [...ALL_FIXTURES, ...RELEASE_MANIFEST_FIXTURES, ...RELEASE_STATUS_FIXTURES]; + describe("skillx.package/v1 fixtures", () => { it("covers both a valid and a rejected case for every contract group", () => { - const groups = new Set(ALL_FIXTURES.map((fixture) => fixture.group)); - expect(groups.size).toBe(6); + const groups = new Set(EVERY_FIXTURE.map((fixture) => fixture.group)); + expect(groups.size).toBe(8); for (const group of groups) { - const cases = ALL_FIXTURES.filter((fixture) => fixture.group === group); + const cases = EVERY_FIXTURE.filter((fixture) => fixture.group === group); expect(cases.some((c) => c.expectedValid), `${group} needs a valid fixture`).toBe(true); expect(cases.some((c) => !c.expectedValid), `${group} needs a rejected fixture`).toBe(true); } }); - it.each(ALL_FIXTURES)("$group :: $name is valid=$expectedValid", ({ group, value, expectedValid, expectedIssueCode }) => { + it("keeps the test-only signed-release fixtures out of the package root", () => { + expect("RELEASE_MANIFEST_FIXTURES" in contracts).toBe(false); + expect("RELEASE_STATUS_FIXTURES" in contracts).toBe(false); + expect(ALL_FIXTURES.some((f) => f.group === "release-manifest" || f.group === "release-status")).toBe(false); + }); + + it.each(EVERY_FIXTURE)("$group :: $name is valid=$expectedValid", ({ group, value, expectedValid, expectedIssueCode }) => { const result = VALIDATOR_BY_GROUP[group]?.(value); expect(result, `no validator registered for group ${group}`).toBeDefined(); expect(result?.valid).toBe(expectedValid); @@ -56,6 +72,8 @@ describe("skillx.package/v1 fixtures", () => { expect(COLLECTION_MEMBER_FIXTURES.length).toBeGreaterThan(0); expect(ENTITLEMENT_FIXTURES.length).toBeGreaterThan(0); expect(INGESTION_FIXTURES.length).toBeGreaterThan(0); + expect(RELEASE_MANIFEST_FIXTURES.length).toBeGreaterThan(0); + expect(RELEASE_STATUS_FIXTURES.length).toBeGreaterThan(0); expect(ALL_FIXTURES.length).toBe( PACKAGE_MANIFEST_FIXTURES.length + VERIFICATION_EVIDENCE_FIXTURES.length + diff --git a/packages/contracts/src/release-manifest.test.ts b/packages/contracts/src/release-manifest.test.ts new file mode 100644 index 0000000..3939098 --- /dev/null +++ b/packages/contracts/src/release-manifest.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "./canonical-json"; +import { + RELEASE_FIXTURE_KEY_ID, + RELEASE_FIXTURE_UNICODE_PATH, + RELEASE_MANIFEST_FIXTURE, + RELEASE_MANIFEST_MINIMAL_FIXTURE, + signedReleaseDraft, +} from "./fixtures/release-fixtures"; +import { + RELEASE_MANIFEST_CODES, + RELEASE_SIGNATURE_CODES, + RELEASE_SIGNATURE_PATTERN, + RELEASE_VERSION_PATTERN, + ReleaseManifestError, + compareUtf8, + releaseSigningPayload, + releaseTreeDigest, + signReleaseManifest, + validateReleaseManifest, + validateUnsignedReleaseManifest, + verifyReleaseManifest, +} from "./release-manifest"; +import type { SkillxReleaseManifest, UnsignedReleaseManifest } from "./release-manifest"; + +const decoder = new TextDecoder(); +const doc = (value: unknown): string => canonicalJson(value); + +async function generateKey(): Promise<{ privateKey: CryptoKey; publicKey: Uint8Array }> { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as CryptoKeyPair; + const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)); + return { privateKey: pair.privateKey, publicKey }; +} + +/** Rebuilds `value` with every object's keys inserted in reverse order. */ +function reverseKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(reverseKeys); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .reverse() + .map(([key, item]) => [key, reverseKeys(item)]), + ); + } + return value; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function flipBase64Byte(signature: string): string { + const bytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)); + bytes[0] = bytes[0]! ^ 0x01; + return btoa(String.fromCharCode(...bytes)); +} + +describe("skillx.release/v1 validation", () => { + it("accepts both unsigned fixtures before signing", () => { + expect(validateUnsignedReleaseManifest(RELEASE_MANIFEST_FIXTURE)).toEqual({ valid: true, issues: [] }); + expect(validateUnsignedReleaseManifest(RELEASE_MANIFEST_MINIMAL_FIXTURE)).toEqual({ valid: true, issues: [] }); + }); + + it("rejects a signature on an unsigned manifest", () => { + const result = validateUnsignedReleaseManifest(signedReleaseDraft()); + expect(result.issues).toContainEqual(expect.objectContaining({ path: "signature", code: "unknown_field" })); + }); + + it("rejects any non-document input without throwing", () => { + // eslint-disable-next-line no-sparse-arrays + const sparseFiles = [, RELEASE_MANIFEST_FIXTURE.files[0]]; + const inputs: unknown[] = [ + null, + undefined, + "skillx.release/v1", + [], + 7, + new Date(0), + new Map(), + Object.create(null), + { ...signedReleaseDraft(), files: sparseFiles }, + { ...signedReleaseDraft(), files: [{ ...RELEASE_MANIFEST_FIXTURE.files[0], path: new String("SKILL.md") }] }, + { ...signedReleaseDraft(), artifactSize: 10240n }, + { ...signedReleaseDraft(), artifactSize: Number.POSITIVE_INFINITY }, + { ...signedReleaseDraft(), identity: `@publisher/${String.fromCharCode(0xdc00)}` }, + ]; + for (const input of inputs) { + expect(() => validateReleaseManifest(input)).not.toThrow(); + expect(validateReleaseManifest(input).valid).toBe(false); + } + }); + + it("accepts SemVer 2.0 versions without build metadata or leading zeros", () => { + for (const version of ["0.0.0", "1.2.3", "10.20.30", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-0.3.7", "1.0.0-x-y.z--", "1.0.0-0a"]) { + expect(RELEASE_VERSION_PATTERN.test(version), version).toBe(true); + } + for (const version of ["1.2", "v1.2.3", "01.2.3", "1.02.3", "1.2.03", "1.2.3-01", "1.2.3+build", "1.2.3-", "1.2.3-a..b", "1.2.3-a_b"]) { + expect(RELEASE_VERSION_PATTERN.test(version), version).toBe(false); + } + const at = (version: string) => ({ ...RELEASE_MANIFEST_FIXTURE, version }); + expect(validateUnsignedReleaseManifest(at(`1.2.0-${"a".repeat(122)}`)).valid).toBe(true); + expect(validateUnsignedReleaseManifest(at(`1.2.0-${"a".repeat(123)}`)).issues.map((i) => i.path)).toEqual(["version"]); + }); + + it("accepts only the one canonical base64 text of a 64-byte signature", () => { + expect(RELEASE_SIGNATURE_PATTERN.test(`${"A".repeat(85)}w==`)).toBe(true); + expect(RELEASE_SIGNATURE_PATTERN.test(`${"A".repeat(85)}B==`)).toBe(false); + expect(RELEASE_SIGNATURE_PATTERN.test(`${"A".repeat(84)}AA=`)).toBe(false); + }); + + it("reports every missing field of an empty object", () => { + const paths = validateReleaseManifest({}).issues.filter((i) => i.code === "required").map((i) => i.path); + expect(paths).toContain("releaseDigest"); + expect(paths).toContain("signature"); + expect(paths).toHaveLength(18); + expect(paths).not.toContain("state"); + }); + + it("rejects unknown nested publisher and provenance fields", () => { + const draft = signedReleaseDraft() as Record>; + draft.publisher!.email = "x@example.com"; + draft.sourceProvenance!.ref = "main"; + const paths = validateReleaseManifest(draft).issues.map((i) => `${i.path}:${i.code}`); + expect(paths).toContain("publisher.email:unknown_field"); + expect(paths).toContain("sourceProvenance.ref:unknown_field"); + }); + + it("accepts a release at the repository root", () => { + const draft = signedReleaseDraft() as Record>; + draft.sourceProvenance!.path = ""; + expect(validateReleaseManifest(draft).valid).toBe(true); + }); + + it("rejects unsorted declared scripts", () => { + const draft = signedReleaseDraft(); + draft.declaredScripts = ["scripts/check.sh", "SKILL.md"]; + expect(validateReleaseManifest(draft).issues.map((i) => i.code)).toContain("order"); + }); + + it("orders paths by UTF-8 bytes, not UTF-16 code units", () => { + // U+FF61 is one UTF-16 unit above the surrogates but sorts before U+1F600 in UTF-8. + expect(compareUtf8(String.fromCodePoint(0xff61), String.fromCodePoint(0x1f600))).toBe(-1); + expect(compareUtf8("a", "a/")).toBe(-1); + expect(compareUtf8("b", "b")).toBe(0); + }); +}); + +describe("skillx.release/v1 signing payload", () => { + it("is byte-stable across member order", async () => { + const reordered = reverseKeys(RELEASE_MANIFEST_FIXTURE) as UnsignedReleaseManifest; + expect(Object.keys(reordered)[0]).toBe("signatureAlgorithm"); + expect(releaseSigningPayload(reordered)).toEqual(releaseSigningPayload(RELEASE_MANIFEST_FIXTURE)); + }); + + it("excludes only the signature member", async () => { + const { privateKey } = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + expect(releaseSigningPayload(signed)).toEqual(releaseSigningPayload(RELEASE_MANIFEST_FIXTURE)); + const text = decoder.decode(releaseSigningPayload(signed)); + expect(text).not.toContain('"signature"'); + expect(text).toContain('"keyId":"skillx-test-vector-1"'); + expect(text).toContain('"signatureAlgorithm":"ed25519"'); + expect(text.startsWith('{"artifactDigest":')).toBe(true); + expect(text).toContain('"sourceProvenance":{"commit":'); + expect(text).toContain(RELEASE_FIXTURE_UNICODE_PATH); + expect(text).not.toContain("\\u2028"); + }); + + it("encodes null provenance and empty script lists literally", () => { + const text = decoder.decode(releaseSigningPayload(RELEASE_MANIFEST_MINIMAL_FIXTURE)); + expect(text).toContain('"declaredScripts":[]'); + expect(text).toContain('"sourceProvenance":null'); + }); +}); + +describe("skillx.release/v1 tree digest", () => { + it("recomputes the fixture release digests with WebCrypto", async () => { + expect(await releaseTreeDigest(RELEASE_MANIFEST_FIXTURE.files)).toBe(RELEASE_MANIFEST_FIXTURE.releaseDigest); + expect(await releaseTreeDigest(RELEASE_MANIFEST_MINIMAL_FIXTURE.files)).toBe(RELEASE_MANIFEST_MINIMAL_FIXTURE.releaseDigest); + }); + + it("refuses to sign a manifest whose releaseDigest is not the tree digest of its files", async () => { + const { privateKey } = await generateKey(); + const wrong = { ...RELEASE_MANIFEST_FIXTURE, releaseDigest: RELEASE_MANIFEST_MINIMAL_FIXTURE.releaseDigest }; + const error = await signReleaseManifest(wrong, privateKey).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ReleaseManifestError); + expect((error as ReleaseManifestError).issues.map((i) => i.code)).toEqual([RELEASE_MANIFEST_CODES.treeDigestMismatch]); + }); +}); + +describe("skillx.release/v1 signatures", () => { + it("verifies a manifest signed with the trusted key, from text or bytes", async () => { + const { privateKey, publicKey } = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + const keys = new Map([[RELEASE_FIXTURE_KEY_ID, publicKey]]); + expect(await verifyReleaseManifest(doc(signed), keys)).toEqual({ valid: true, issues: [] }); + expect(await verifyReleaseManifest(new TextEncoder().encode(doc(signed)), keys)).toEqual({ valid: true, issues: [] }); + }); + + it.each<[string, (text: string) => string]>([ + ["reordered members", (t) => JSON.stringify(reverseKeys(JSON.parse(t)))], + ["pretty-printed", (t) => JSON.stringify(JSON.parse(t), null, 2)], + ["a duplicate member", (t) => t.replace("{", '{"version":"9.9.9",')], + ["an exponent number form", (t) => t.replace('"artifactSize":10240', '"artifactSize":1.024e4')], + ["an escaped slash", (t) => t.replace("scripts/check.sh", "scripts\\/check.sh")], + ])("rejects %s as non-canonical", async (_name, mangle) => { + const { privateKey, publicKey } = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + const result = await verifyReleaseManifest(mangle(doc(signed)), new Map([[RELEASE_FIXTURE_KEY_ID, publicKey]])); + expect(result.issues.map((i) => i.code)).toEqual([RELEASE_MANIFEST_CODES.nonCanonical]); + }); + + it.each<[string, (m: SkillxReleaseManifest) => void, string]>([ + ["a changed version", (m) => void (m.version = "1.2.1"), RELEASE_SIGNATURE_CODES.mismatch], + [ + "a changed file hash", + (m) => void (m.files[0]!.sha256 = `${m.files[0]!.sha256.slice(0, 63)}0`), + RELEASE_MANIFEST_CODES.treeDigestMismatch, + ], + ["a changed release digest", (m) => void (m.releaseDigest = `sha256:${"e".repeat(64)}`), RELEASE_MANIFEST_CODES.treeDigestMismatch], + ["a changed publisher verification", (m) => void (m.publisher.verifiedBy = "admin"), RELEASE_SIGNATURE_CODES.mismatch], + ["a flipped signature byte", (m) => void (m.signature = flipBase64Byte(m.signature)), RELEASE_SIGNATURE_CODES.mismatch], + ])("rejects %s", async (_name, tamper, code) => { + const { privateKey, publicKey } = await generateKey(); + const signed = clone(await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey)); + tamper(signed); + expect(validateReleaseManifest(signed).valid).toBe(true); + const result = await verifyReleaseManifest(doc(signed), new Map([[RELEASE_FIXTURE_KEY_ID, publicKey]])); + expect(result.valid).toBe(false); + expect(result.issues.map((i) => i.code)).toEqual([code]); + }); + + it("rejects a signature from another key under the trusted key ID", async () => { + const signer = await generateKey(); + const trusted = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, signer.privateKey); + const result = await verifyReleaseManifest(doc(signed), new Map([[RELEASE_FIXTURE_KEY_ID, trusted.publicKey]])); + expect(result.issues.map((i) => i.code)).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + + it("rejects a key ID swapped to another trusted key, because the key ID is signed", async () => { + const a = await generateKey(); + const b = await generateKey(); + const signed = clone(await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, a.privateKey)); + signed.keyId = "skillx-other-key"; + const keys = new Map([ + [RELEASE_FIXTURE_KEY_ID, a.publicKey], + ["skillx-other-key", a.publicKey], + ["skillx-third-key", b.publicKey], + ]); + const result = await verifyReleaseManifest(doc(signed), keys); + expect(result.issues.map((i) => i.code)).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + + it("rejects an unknown key ID", async () => { + const { privateKey, publicKey } = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + const result = await verifyReleaseManifest(doc(signed), new Map([["skillx-other-key", publicKey]])); + expect(result.issues.map((i) => i.code)).toEqual([RELEASE_SIGNATURE_CODES.unknownKey]); + }); + + it("rejects a malformed public key without throwing", async () => { + const { privateKey } = await generateKey(); + const signed = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + const result = await verifyReleaseManifest(doc(signed), new Map([[RELEASE_FIXTURE_KEY_ID, new Uint8Array(31)]])); + expect(result.issues.map((i) => i.code)).toEqual([RELEASE_SIGNATURE_CODES.invalidKey]); + }); + + it("returns structural issues for an invalid manifest instead of verifying", async () => { + const { publicKey } = await generateKey(); + const result = await verifyReleaseManifest(doc({ schemaVersion: "skillx.release/v1" }), new Map([[RELEASE_FIXTURE_KEY_ID, publicKey]])); + expect(result.valid).toBe(false); + expect(result.issues.map((i) => i.code)).not.toContain(RELEASE_SIGNATURE_CODES.mismatch); + }); + + it("refuses to sign a manifest that carries a mutable release state", async () => { + const { privateKey } = await generateKey(); + const invalid = { ...RELEASE_MANIFEST_FIXTURE, state: "published" } as unknown as UnsignedReleaseManifest; + await expect(signReleaseManifest(invalid, privateKey)).rejects.toBeInstanceOf(ReleaseManifestError); + }); + + it("refuses to sign with a verify-only key", async () => { + const { publicKey } = await generateKey(); + const verifyKey = await crypto.subtle.importKey("raw", new Uint8Array(publicKey), { name: "Ed25519" }, false, ["verify"]); + await expect(signReleaseManifest(RELEASE_MANIFEST_FIXTURE, verifyKey)).rejects.toBeInstanceOf(ReleaseManifestError); + }); +}); diff --git a/packages/contracts/src/release-manifest.ts b/packages/contracts/src/release-manifest.ts new file mode 100644 index 0000000..59925b3 --- /dev/null +++ b/packages/contracts/src/release-manifest.ts @@ -0,0 +1,482 @@ +/** + * `skillx.release/v1` signed release manifest contract. + * + * A release manifest pins one immutable package version to the canonical + * `agentkit.skill-tree/v1` tree of its files. `releaseDigest` is the tree + * digest and the release identity; `artifactDigest` covers only the bytes of + * the transport archive. The manifest is signed once per `releaseDigest` and + * never changes; the mutable release state lives in the separately signed + * `skillx.release-status/v1` document (`release-status.ts`). Signature rules + * are in `release-signature.ts`: Ed25519 over the canonical JSON of every + * member except `signature`. `releaseDigest` must equal the tree digest of + * `files`; signing and verification both enforce it. + * + * Validators and verifiers never throw, whatever the input. + */ + +import { + ERROR_CODES, + SkillTreeError, + checkCollisions, + checkPublisher, + compareUtf8, + isNoisePath, + serializeTree, + validatePath, +} from "@skillx/skill-tree/rules"; +import { canonicalJsonBytes } from "./canonical-json"; +import { PACKAGE_NAME_PATTERN, RELEASE_CHANNELS, isPackageKind, PACKAGE_KINDS } from "./package-manifest"; +import type { PackageKind, ReleaseChannel } from "./package-manifest"; +import { + RELEASE_SIGNATURE_ALGORITHM, + ReleaseManifestError, + checkCanonicalValue, + checkFields as checkSchemaFields, + checkSignatureMembers, + isReleaseTimestamp, + parseReceivedDocument, + sha256Digest, + signPayload, + verifyPayload, +} from "./release-signature"; +import type { ReleasePublicKeys } from "./release-signature"; +import { isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; +import { RELEASE_DIGEST_PATTERN } from "./verification-evidence"; + +export { + RELEASE_KEY_ID_PATTERN, + RELEASE_SIGNATURE_ALGORITHM, + RELEASE_SIGNATURE_CODES, + RELEASE_SIGNATURE_PATTERN, + RELEASE_TIMESTAMP_PATTERN, + ReleaseManifestError, +} from "./release-signature"; +export type { ReleasePublicKeys } from "./release-signature"; +export { compareUtf8 } from "@skillx/skill-tree/rules"; + +export const SKILLX_RELEASE_SCHEMA_VERSION = "skillx.release/v1"; + +/** Verification failure codes of the release manifest itself. */ +export const RELEASE_MANIFEST_CODES = { + /** The received bytes are not exactly the canonical JSON of the document. */ + nonCanonical: "release.non_canonical", + /** `releaseDigest` is not the `agentkit.skill-tree/v1` digest of `files`. */ + treeDigestMismatch: "release.tree_digest_mismatch", +} as const; + +/** + * SemVer 2.0 version of a release: no build metadata (`+...` has no + * precedence, so two releases could not be ordered) and no leading zeros in + * numeric identifiers. Stricter than the package-wide `SEMVER_PATTERN`. + */ +export const RELEASE_VERSION_PATTERN = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/; +const MAX_RELEASE_VERSION_LENGTH = 128; + +/** Mode classes of `agentkit.skill-tree/v1`. */ +export const RELEASE_FILE_MODES = ["100644", "100755"] as const; +export type ReleaseFileMode = (typeof RELEASE_FILE_MODES)[number]; + +/** + * How SkillX verified that the publisher owns its handle. The signature is a + * registry key, never a publisher key, so consumers MUST NOT map these + * releases to trust `publisher-signed`. AgentKit will expose a verified + * registry signature as `registry-signed` once its skill-lifecycle contract + * adds that value; until then its trust mapping is defined there. + */ +export const PUBLISHER_VERIFICATIONS = ["none", "github", "admin"] as const; +export type PublisherVerification = (typeof PUBLISHER_VERIFICATIONS)[number]; + +/** Per-file content hash: bare lowercase hex SHA-256. */ +export const FILE_SHA256_PATTERN = /^[a-f0-9]{64}$/; +/** A full SHA-1 or SHA-256 git object name in lowercase hex. */ +export const SOURCE_COMMIT_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const MAX_SOURCE_REPO_LENGTH = 512; + +/** Tree limits of `agentkit.skill-tree/v1`; equal to `LIMITS` of `@skillx/skill-tree` (guarded by a test). */ +export const RELEASE_TREE_LIMITS = { + maxFiles: 4_096, + maxFileSize: 32 * 1024 * 1024, + maxTreeSize: 256 * 1024 * 1024, +} as const; + +export interface ReleaseFile { + path: string; + mode: ReleaseFileMode; + /** Lowercase hex SHA-256 of the file content. */ + sha256: string; + size: number; +} + +export interface ReleasePublisher { + /** The publisher segment of `identity`. */ + handle: string; + verifiedBy: PublisherVerification; +} + +export interface ReleaseSourceProvenance { + /** HTTPS URL of the source repository. */ + repo: string; + commit: string; + /** Package root inside the repository; `""` for the repository root. */ + path: string; +} + +export interface UnsignedReleaseManifest { + schemaVersion: typeof SKILLX_RELEASE_SCHEMA_VERSION; + identity: string; + version: string; + channel: ReleaseChannel; + kind: PackageKind; + /** `treeDigest` of `files`: `sha256:<64 lowercase hex>`. */ + releaseDigest: string; + /** Transport archive digest; never an identity. */ + artifactDigest: string; + artifactSize: number; + /** Sorted by the raw UTF-8 bytes of `path`. */ + files: ReleaseFile[]; + fileCount: number; + treeSize: number; + publisher: ReleasePublisher; + /** + * `null` when the release was not packed from a recorded git commit. + * Consumers MUST label such a release "provenance unknown". + */ + sourceProvenance: ReleaseSourceProvenance | null; + /** Paths of `files` the package declares as scripts, sorted like `files`. Never executed by SkillX. */ + declaredScripts: string[]; + createdAt: string; + keyId: string; + signatureAlgorithm: typeof RELEASE_SIGNATURE_ALGORITHM; +} + +export interface SkillxReleaseManifest extends UnsignedReleaseManifest { + signature: string; +} + +const MANIFEST_FIELDS = [ + "schemaVersion", + "identity", + "version", + "channel", + "kind", + "releaseDigest", + "artifactDigest", + "artifactSize", + "files", + "fileCount", + "treeSize", + "publisher", + "sourceProvenance", + "declaredScripts", + "createdAt", + "keyId", + "signatureAlgorithm", +] as const; +const FILE_FIELDS = ["path", "mode", "sha256", "size"] as const; +const PUBLISHER_FIELDS = ["handle", "verifiedBy"] as const; +const PROVENANCE_FIELDS = ["repo", "commit", "path"] as const; + +const isSize = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; + +function oneOf(values: readonly T[], value: unknown): value is T { + return typeof value === "string" && (values as readonly string[]).includes(value); +} + +function checkFields(value: Record, allowed: readonly string[], path: string, issues: ValidationIssue[]): void { + checkSchemaFields(value, allowed, path, SKILLX_RELEASE_SCHEMA_VERSION, issues); +} + +function treeIssue(path: string, error: SkillTreeError): ValidationIssue { + return issue(path, error.code, error.message); +} + +function validateIdentityField(identity: unknown, issues: ValidationIssue[]): string | null { + if (typeof identity !== "string" || !PACKAGE_NAME_PATTERN.test(identity)) { + issues.push(issue("identity", "pattern", "must be '@publisher/slug' using lowercase slug characters")); + return null; + } + try { + // Every SkillX release comes from the `skillx` provider, so `@agentkit/*` is always reserved. + checkPublisher(identity, "skillx"); + } catch (error) { + if (!(error instanceof SkillTreeError)) throw error; + issues.push(treeIssue("identity", error)); + return null; + } + return identity.slice(1, identity.indexOf("/")); +} + +function validateFiles(files: unknown, issues: ValidationIssue[]): ReleaseFile[] | null { + if (!Array.isArray(files)) { + issues.push(issue("files", "type", "must be an array")); + return null; + } + if (files.length === 0) issues.push(issue("files", ERROR_CODES.empty, "a release has at least one file")); + if (files.length > RELEASE_TREE_LIMITS.maxFiles) { + issues.push(issue("files", ERROR_CODES.limitExceeded, `has more than ${RELEASE_TREE_LIMITS.maxFiles} files`)); + return null; + } + const before = issues.length; + let previous: string | null = null; + files.forEach((file: unknown, i) => { + const at = `files[${i}]`; + if (!isPlainObject(file)) { + issues.push(issue(at, "type", "must be an object")); + return; + } + checkFields(file, FILE_FIELDS, at, issues); + const { path, mode, sha256, size } = file; + if (typeof path !== "string") { + issues.push(issue(`${at}.path`, "type", "must be a string")); + } else { + const pathError = validatePath(path); + if (pathError !== null) issues.push(treeIssue(`${at}.path`, pathError)); + else if (isNoisePath(path)) issues.push(issue(`${at}.path`, ERROR_CODES.unsafePath, "is excluded noise that no tree contains")); + if (previous !== null) { + const order = compareUtf8(previous, path); + if (order === 0) issues.push(issue(`${at}.path`, "duplicate", `duplicates ${JSON.stringify(path)}`)); + else if (order > 0) issues.push(issue(`${at}.path`, "order", "files must be sorted by raw UTF-8 path bytes")); + } + previous = path; + } + if (!oneOf(RELEASE_FILE_MODES, mode)) { + issues.push(issue(`${at}.mode`, "unsupported_value", `must be one of: ${RELEASE_FILE_MODES.join(", ")}`)); + } + if (typeof sha256 !== "string" || !FILE_SHA256_PATTERN.test(sha256)) { + issues.push(issue(`${at}.sha256`, "pattern", "must be 64 lowercase hex characters")); + } + if (!isSize(size)) issues.push(issue(`${at}.size`, "type", "must be a non-negative safe integer")); + else if (size > RELEASE_TREE_LIMITS.maxFileSize) { + issues.push(issue(`${at}.size`, ERROR_CODES.limitExceeded, `exceeds ${RELEASE_TREE_LIMITS.maxFileSize} bytes`)); + } + }); + if (issues.length !== before) return null; + const valid = files as ReleaseFile[]; + const collision = checkCollisions(valid.map((f) => f.path), []); + if (collision !== null) { + issues.push(treeIssue("files", collision)); + return null; + } + return valid; +} + +function validatePublisher(publisher: unknown, handle: string | null, issues: ValidationIssue[]): void { + if (!isPlainObject(publisher)) { + issues.push(issue("publisher", "type", "must be an object")); + return; + } + checkFields(publisher, PUBLISHER_FIELDS, "publisher", issues); + if (typeof publisher.handle !== "string") { + issues.push(issue("publisher.handle", "type", "must be a string")); + } else if (handle !== null && publisher.handle !== handle) { + issues.push(issue("publisher.handle", "mismatch", `must equal the identity publisher ${JSON.stringify(handle)}`)); + } + if (!oneOf(PUBLISHER_VERIFICATIONS, publisher.verifiedBy)) { + issues.push(issue("publisher.verifiedBy", "unsupported_value", `must be one of: ${PUBLISHER_VERIFICATIONS.join(", ")}`)); + } +} + +function isHttpsRepo(value: unknown): boolean { + if (typeof value !== "string" || value.length > MAX_SOURCE_REPO_LENGTH || !/^https:\/\/[\x21-\x7e]+$/.test(value)) return false; + try { + const url = new URL(value); + return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname !== ""; + } catch { + return false; + } +} + +function validateProvenance(provenance: unknown, issues: ValidationIssue[]): void { + if (provenance === null) return; + if (!isPlainObject(provenance)) { + issues.push(issue("sourceProvenance", "type", "must be an object or null")); + return; + } + checkFields(provenance, PROVENANCE_FIELDS, "sourceProvenance", issues); + if (!isHttpsRepo(provenance.repo)) { + issues.push(issue("sourceProvenance.repo", "format", `must be an https URL without credentials, at most ${MAX_SOURCE_REPO_LENGTH} characters`)); + } + if (typeof provenance.commit !== "string" || !SOURCE_COMMIT_PATTERN.test(provenance.commit)) { + issues.push(issue("sourceProvenance.commit", "pattern", "must be a full lowercase hex git commit")); + } + if (typeof provenance.path !== "string") { + issues.push(issue("sourceProvenance.path", "type", "must be a string")); + } else if (provenance.path !== "") { + const pathError = validatePath(provenance.path); + if (pathError !== null) issues.push(treeIssue("sourceProvenance.path", pathError)); + } +} + +function validateDeclaredScripts(scripts: unknown, files: ReleaseFile[] | null, issues: ValidationIssue[]): void { + if (!Array.isArray(scripts)) { + issues.push(issue("declaredScripts", "type", "must be an array of file paths")); + return; + } + const paths = files === null ? null : new Set(files.map((f) => f.path)); + let previous: string | null = null; + scripts.forEach((script: unknown, i) => { + const at = `declaredScripts[${i}]`; + if (typeof script !== "string") { + issues.push(issue(at, "type", "must be a string")); + return; + } + if (paths !== null && !paths.has(script)) issues.push(issue(at, "not_found", "must name a path in files")); + if (previous !== null && compareUtf8(previous, script) >= 0) { + issues.push(issue(at, "order", "must be unique and sorted by raw UTF-8 path bytes")); + } + previous = script; + }); +} + +function collectIssues(input: unknown, requireSignature: boolean): ValidationIssue[] { + if (!isPlainObject(input)) return [issue("", "type", "release manifest must be an object")]; + const issues: ValidationIssue[] = []; + if (!checkCanonicalValue(input, issues)) return issues; + checkFields(input, requireSignature ? [...MANIFEST_FIELDS, "signature"] : MANIFEST_FIELDS, "", issues); + + if (input.schemaVersion !== SKILLX_RELEASE_SCHEMA_VERSION) { + issues.push(issue("schemaVersion", "unsupported_value", `must equal '${SKILLX_RELEASE_SCHEMA_VERSION}'`)); + } + const handle = validateIdentityField(input.identity, issues); + if ( + typeof input.version !== "string" || + input.version.length > MAX_RELEASE_VERSION_LENGTH || + !RELEASE_VERSION_PATTERN.test(input.version) + ) { + issues.push( + issue( + "version", + "pattern", + `must be a SemVer 2.0 version without build metadata or leading zeros, at most ${MAX_RELEASE_VERSION_LENGTH} characters, such as '0.1.0'`, + ), + ); + } + if (!oneOf(RELEASE_CHANNELS, input.channel)) { + issues.push(issue("channel", "unsupported_value", `must be one of: ${RELEASE_CHANNELS.join(", ")}`)); + } + if (!isPackageKind(input.kind)) { + issues.push(issue("kind", "unsupported_value", `must be one of: ${PACKAGE_KINDS.join(", ")}`)); + } + for (const field of ["releaseDigest", "artifactDigest"] as const) { + const value = input[field]; + if (typeof value !== "string" || !RELEASE_DIGEST_PATTERN.test(value)) { + issues.push(issue(field, "pattern", "must match sha256:<64 lowercase hex>")); + } + } + if (!isSize(input.artifactSize) || input.artifactSize === 0) { + issues.push(issue("artifactSize", "type", "must be a positive safe integer")); + } + + const files = validateFiles(input.files, issues); + if (!isSize(input.fileCount)) { + issues.push(issue("fileCount", "type", "must be a non-negative safe integer")); + } else if (files !== null && input.fileCount !== files.length) { + issues.push(issue("fileCount", "mismatch", "must equal the number of files")); + } + if (!isSize(input.treeSize)) { + issues.push(issue("treeSize", "type", "must be a non-negative safe integer")); + } else if (input.treeSize > RELEASE_TREE_LIMITS.maxTreeSize) { + issues.push(issue("treeSize", ERROR_CODES.limitExceeded, `exceeds ${RELEASE_TREE_LIMITS.maxTreeSize} bytes`)); + } else if (files !== null && input.treeSize !== files.reduce((sum, f) => sum + f.size, 0)) { + issues.push(issue("treeSize", "mismatch", "must equal the sum of file sizes")); + } + + validatePublisher(input.publisher, handle, issues); + validateProvenance(input.sourceProvenance, issues); + validateDeclaredScripts(input.declaredScripts, files, issues); + if (!isReleaseTimestamp(input.createdAt)) { + issues.push(issue("createdAt", "format", "must be an RFC 3339 UTC timestamp with whole seconds, e.g. 2026-10-13T04:30:00Z")); + } + checkSignatureMembers(input, requireSignature, issues); + return issues; +} + +/** Validates a signed `skillx.release/v1` manifest (structure only, not the signature). */ +export function validateReleaseManifest(input: unknown): ValidationResult { + const issues = collectIssues(input, true); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** Validates a manifest before it is signed: every field except `signature`, which must be absent. */ +export function validateUnsignedReleaseManifest(input: unknown): ValidationResult { + const issues = collectIssues(input, false); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** + * The exact bytes an Ed25519 release signature covers: the canonical JSON of + * the manifest without its `signature` member, UTF-8 encoded. + */ +export function releaseSigningPayload(manifest: UnsignedReleaseManifest | SkillxReleaseManifest): Uint8Array { + const { signature: _signature, ...payload } = manifest as Partial; + return canonicalJsonBytes(payload); +} + +/** + * The manifest digest that binds a `skillx.release-status/v1` document to one + * release: `sha256:<64 lowercase hex>` of `releaseSigningPayload(manifest)`, + * the exact bytes the manifest signature covers (so it includes `keyId` and + * `signatureAlgorithm` but not `signature`). Two releases of the same tree + * under different identities or versions have different manifest digests. + */ +export async function releaseManifestDigest(manifest: UnsignedReleaseManifest | SkillxReleaseManifest): Promise { + return sha256Digest(releaseSigningPayload(manifest)); +} + +/** + * The `agentkit.skill-tree/v1` digest of `files`, computed with WebCrypto over + * the pure serialization of `@skillx/skill-tree/rules`. Equal to `treeDigest` + * of `@skillx/skill-tree` for the same files. + */ +export async function releaseTreeDigest(files: readonly ReleaseFile[]): Promise { + return sha256Digest(new TextEncoder().encode(serializeTree(files))); +} + +async function treeDigestIssue(manifest: UnsignedReleaseManifest): Promise { + const digest = await releaseTreeDigest(manifest.files); + return digest === manifest.releaseDigest + ? null + : issue("releaseDigest", RELEASE_MANIFEST_CODES.treeDigestMismatch, `is not the tree digest of files (${digest})`); +} + +/** + * Signs a valid unsigned manifest with an Ed25519 private key. The key ID and + * algorithm are part of the signed payload. Throws `ReleaseManifestError` when + * the manifest is invalid, its `releaseDigest` is not the tree digest of its + * `files`, or the key cannot sign with Ed25519. + */ +export async function signReleaseManifest( + manifest: UnsignedReleaseManifest, + privateKey: CryptoKey, +): Promise { + const result = validateUnsignedReleaseManifest(manifest); + if (!result.valid) throw new ReleaseManifestError("invalid release manifest", result.issues); + const mismatch = await treeDigestIssue(manifest); + if (mismatch !== null) throw new ReleaseManifestError("invalid release manifest", [mismatch]); + return { ...manifest, signature: await signPayload(releaseSigningPayload(manifest), privateKey) }; +} + +/** + * Verifies a received manifest document against `publicKeys`, in this order: + * the bytes are exactly its canonical JSON (`release.non_canonical`), the + * structure is valid, `releaseDigest` is the tree digest of `files` + * (`release.tree_digest_mismatch`), and the signature verifies. Fails closed: + * every failure is an invalid result, never an exception. The verified + * manifest is the parsed `document`. + */ +export async function verifyReleaseManifest( + document: string | Uint8Array, + publicKeys: ReleasePublicKeys, +): Promise { + const parsed = parseReceivedDocument(document, RELEASE_MANIFEST_CODES.nonCanonical); + if (!("value" in parsed)) return parsed; + const structure = validateReleaseManifest(parsed.value); + if (!structure.valid) return structure; + const manifest = parsed.value as SkillxReleaseManifest; + const mismatch = await treeDigestIssue(manifest); + if (mismatch !== null) return validationFail([mismatch]); + const failure = await verifyPayload(releaseSigningPayload(manifest), manifest.signature, manifest.keyId, publicKeys); + return failure === null ? validationOk() : validationFail([failure]); +} diff --git a/packages/contracts/src/release-signature.ts b/packages/contracts/src/release-signature.ts new file mode 100644 index 0000000..30c4c71 --- /dev/null +++ b/packages/contracts/src/release-signature.ts @@ -0,0 +1,188 @@ +/** + * Signature, key and timestamp rules shared by the signed SkillX release + * documents (`skillx.release/v1` and `skillx.release-status/v1`). + * + * A document is signed with Ed25519 over the UTF-8 canonical JSON (see + * `canonical-json.ts`) of every member except `signature`, so `keyId` and + * `signatureAlgorithm` are signed too. The signature is the standard (padded) + * base64 of the 64-byte Ed25519 signature, as in AgentKit's remote registry + * manifests. WebCrypto `Ed25519` works in Workers, browsers and Node.js. + * + * Domain separation: there is no prefix before the payload. Every signed + * payload holds its own `schemaVersion`, every verifier requires its one + * schema version and rejects unknown members, and registry release keys sign + * nothing but these two schemas, so a signature over one document type never + * verifies as the other. + * + * Served documents are exactly `canonicalJson(document)`, signature included; + * verifiers parse with `parseCanonicalJson`, which rejects any other bytes. + */ + +import { CanonicalJsonError, canonicalJson, parseCanonicalJson } from "./canonical-json"; +import { issue, validationFail } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; + +export const RELEASE_SIGNATURE_ALGORITHM = "ed25519"; +/** Same key ID rule as AgentKit remote registry manifests. */ +export const RELEASE_KEY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,63}$/; +/** RFC 3339 UTC timestamp with whole seconds, e.g. `2026-10-13T04:30:00Z`. */ +export const RELEASE_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +/** + * Padded standard base64 of a 64-byte Ed25519 signature. The last data + * character carries 4 unused bits that must be zero, so each signature has + * exactly one accepted text. + */ +export const RELEASE_SIGNATURE_PATTERN = /^[A-Za-z0-9+/]{85}[AQgw]==$/; + +/** + * Checks that `input` lies in the canonical JSON value space (plain objects, + * dense arrays, non-negative safe integers, well-formed strings). Validators + * call this first so that no later step can throw on programmatic input. + */ +export function checkCanonicalValue(input: unknown, issues: ValidationIssue[]): boolean { + try { + canonicalJson(input); + return true; + } catch (error) { + if (!(error instanceof CanonicalJsonError)) throw error; + issues.push(issue("", "type", `is outside the canonical JSON value space: ${error.message}`)); + return false; + } +} + +/** + * Parses received document bytes for a verifier. Returns the value, or the + * failed result with `code` (e.g. `release.non_canonical`) when the bytes are + * not exactly the canonical JSON of their value. + */ +export function parseReceivedDocument(input: string | Uint8Array, code: string): { value: unknown } | ValidationResult { + const parsed = parseCanonicalJson(input); + return parsed.ok ? { value: parsed.value } : validationFail([issue("", code, `document ${parsed.reason}`)]); +} + +/** Signature verification failure codes. */ +export const RELEASE_SIGNATURE_CODES = { + unknownKey: "signature.unknown_key", + invalidKey: "signature.invalid_key", + mismatch: "signature.mismatch", +} as const; + +/** A key set: key ID to raw 32-byte Ed25519 public key. */ +export type ReleasePublicKeys = ReadonlyMap; + +const ED25519 = { name: "Ed25519" } as const; +const ED25519_PUBLIC_KEY_BYTES = 32; + +/** Reports unknown and missing members of one object level of a `schema` document. */ +export function checkFields( + value: Record, + required: readonly string[], + path: string, + schema: string, + issues: ValidationIssue[], + optional: readonly string[] = [], +): void { + const prefix = path === "" ? "" : `${path}.`; + for (const key of Object.keys(value)) { + if (!required.includes(key) && !optional.includes(key)) { + issues.push(issue(`${prefix}${key}`, "unknown_field", `is not a ${schema} field`)); + } + } + for (const key of required) { + if (!(key in value)) issues.push(issue(`${prefix}${key}`, "required", "is required")); + } +} + +/** `sha256:<64 lowercase hex>` of `bytes`, computed with WebCrypto. */ +export async function sha256Digest(bytes: Uint8Array): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return `sha256:${Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("")}`; +} + +/** Milliseconds since the epoch of a valid release timestamp. */ +export function timestampMs(value: string): number { + return Date.parse(value); +} + +export function isReleaseTimestamp(value: unknown): boolean { + if (typeof value !== "string" || !RELEASE_TIMESTAMP_PATTERN.test(value)) return false; + const ms = Date.parse(value); + // Round-tripping rejects calendar overflow such as 2026-02-30. + return Number.isFinite(ms) && new Date(ms).toISOString() === `${value.slice(0, -1)}.000Z`; +} + +/** Pushes the issues of the signed `keyId`, `signatureAlgorithm` and (when required) `signature` members. */ +export function checkSignatureMembers(input: Record, requireSignature: boolean, issues: ValidationIssue[]): void { + if (typeof input.keyId !== "string" || !RELEASE_KEY_ID_PATTERN.test(input.keyId)) { + issues.push(issue("keyId", "pattern", `must match ${RELEASE_KEY_ID_PATTERN.source}`)); + } + if (input.signatureAlgorithm !== RELEASE_SIGNATURE_ALGORITHM) { + issues.push(issue("signatureAlgorithm", "unsupported_value", `must equal '${RELEASE_SIGNATURE_ALGORITHM}'`)); + } + if (requireSignature && "signature" in input) { + if (typeof input.signature !== "string" || !RELEASE_SIGNATURE_PATTERN.test(input.signature)) { + issues.push(issue("signature", "pattern", "must be padded base64 of a 64-byte Ed25519 signature")); + } + } +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function fromBase64(text: string): Uint8Array { + const binary = atob(text); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +/** Thrown by `signReleaseManifest` and `signReleaseStatus` for an invalid document or key. */ +export class ReleaseManifestError extends Error { + readonly issues: ValidationIssue[]; + constructor(message: string, issues: ValidationIssue[] = []) { + super(issues.length === 0 ? message : `${message}: ${issues.map((i) => `${i.path} ${i.code}`).join(", ")}`); + this.name = "ReleaseManifestError"; + this.issues = issues; + } +} + +/** + * Signs `payload` and returns the padded base64 signature. Throws + * `ReleaseManifestError` when the key cannot sign with Ed25519. + */ +export async function signPayload(payload: Uint8Array, privateKey: CryptoKey): Promise { + try { + return toBase64(new Uint8Array(await crypto.subtle.sign(ED25519, privateKey, payload))); + } catch (cause) { + throw new ReleaseManifestError(`cannot sign with the given key: ${cause instanceof Error ? cause.message : String(cause)}`); + } +} + +/** + * Verifies a structurally valid signature over `payload`. Returns `null` on + * success, otherwise the single failure issue. Never throws. + */ +export async function verifyPayload( + payload: Uint8Array, + signature: string, + keyId: string, + publicKeys: ReleasePublicKeys, +): Promise { + const raw = publicKeys.get(keyId); + if (raw === undefined) return issue("keyId", RELEASE_SIGNATURE_CODES.unknownKey, `no trusted key ${JSON.stringify(keyId)}`); + if (raw.length !== ED25519_PUBLIC_KEY_BYTES) { + return issue("keyId", RELEASE_SIGNATURE_CODES.invalidKey, `key ${JSON.stringify(keyId)} is not 32 bytes`); + } + let verified = false; + try { + const key = await crypto.subtle.importKey("raw", new Uint8Array(raw), ED25519, false, ["verify"]); + verified = await crypto.subtle.verify(ED25519, key, fromBase64(signature), payload); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + return issue("keyId", RELEASE_SIGNATURE_CODES.invalidKey, `key ${JSON.stringify(keyId)} is unusable: ${detail}`); + } + return verified ? null : issue("signature", RELEASE_SIGNATURE_CODES.mismatch, "signature does not verify for the canonical payload"); +} diff --git a/packages/contracts/src/release-status-vectors.test.ts b/packages/contracts/src/release-status-vectors.test.ts new file mode 100644 index 0000000..1ae1284 --- /dev/null +++ b/packages/contracts/src/release-status-vectors.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "./canonical-json"; +import { RELEASE_FIXTURE_KEY_ID, RELEASE_MANIFEST_FIXTURE, RELEASE_MANIFEST_MINIMAL_FIXTURE } from "./fixtures/release-fixtures"; +import { + RELEASE_FIXTURE_MANIFEST_DIGEST, + RELEASE_MINIMAL_FIXTURE_MANIFEST_DIGEST, + RELEASE_STATUS_FIXTURE_REASON, + RELEASE_STATUS_PUBLISHED_FIXTURE, + RELEASE_STATUS_YANKED_FIXTURE, +} from "./fixtures/release-status-fixtures"; +import vectorFile from "./fixtures/release-status-v1.json"; +import { RELEASE_SIGNATURE_CODES, releaseManifestDigest, signReleaseManifest, verifyReleaseManifest } from "./release-manifest"; +import type { SkillxReleaseManifest } from "./release-manifest"; +import { signPayload } from "./release-signature"; +import { + RELEASE_STATUS_CODES, + RELEASE_STATUS_MAX_LIFETIME_SECONDS, + RELEASE_STATUS_REASON_MAX_BYTES, + releaseStatusSigningPayload, + verifyReleaseStatus, +} from "./release-status"; +import type { SkillxReleaseStatus, UnsignedReleaseStatus } from "./release-status"; +import { + OTHER_VECTOR_KEY_SEED_TEXT, + VECTOR_KEY_SEED_TEXT, + base64, + clone, + decoder, + describeVectorKey, + fromBase64, + goJsonStyle, + hex, + sha256, + vectorKey, + withNonZeroPaddingBits, + writeVectorFile, + writeVectorsRequested, +} from "./test-support/vector-key"; +import type { VectorKey, VectorKeyDescription } from "./test-support/vector-key"; + +/** + * Golden cross-language vectors for `skillx.release-status/v1`, exported for + * AgentKit (`fixtures/release-status-v1.json`). Same public test key as the + * manifest vectors. `manifests` lists the signed manifest documents the + * statuses refer to with their manifest digests. Each vector names the + * verified manifest it is checked against (`manifest`), the consumer's clock + * (`now`), the status document it accepted before for that manifest + * (`previous`, or `null`) and the received `document`. + * + * Regenerate after an intentional contract change with + * `SKILLX_WRITE_RELEASE_VECTORS=1 pnpm vitest run packages/contracts/src`. + */ + +interface StatusVector { + name: string; + manifest: string; + now: string; + previous: string | null; + document: string; +} + +interface StatusVectors { + description: string; + canonicalization: string; + manifestDigestRule: string; + consumerRules: string[]; + testKey: VectorKeyDescription; + manifests: Array<{ name: string; document: string; manifestDigest: string }>; + valid: Array; + invalid: Array; +} + +const V120 = "1.2.0 of the multi-file tree"; +const V121 = "1.2.1 of the same tree"; +const MINIMAL = "minimal release"; +const DAY_ONE = "2026-10-13T12:00:00Z"; +const DAY_TWO = "2026-10-14T12:00:00Z"; +const DAY_THREE = "2026-10-15T12:00:00Z"; +/** 300 seconds before the published fixture's issuedAt (2026-10-13T04:31:00Z). */ +const SKEW_EDGE = "2026-10-13T04:26:00Z"; +/** One day after the yanked fixture: the next daily re-sign. */ +const NEXT_DAY = { issuedAt: "2026-10-15T09:00:00Z", expiresAt: "2026-10-16T09:00:00Z" }; + +/** Signs any status payload, including ones `signReleaseStatus` refuses (e.g. an unknown state). */ +async function signRaw(status: Record, key: VectorKey): Promise { + const signature = await signPayload(releaseStatusSigningPayload(status as unknown as UnsignedReleaseStatus), key.privateKey); + return { ...(status as unknown as UnsignedReleaseStatus), signature }; +} + +async function buildVectors(): Promise { + const key = await vectorKey(VECTOR_KEY_SEED_TEXT); + const other = await vectorKey(OTHER_VECTOR_KEY_SEED_TEXT); + const sign = (status: Record) => signRaw(status, key); + const yankedWith = (over: Record) => sign({ ...RELEASE_STATUS_YANKED_FIXTURE, ...over }); + const publishedWith = (over: Record) => sign({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, ...over }); + + // The same tree released as 1.2.0 and 1.2.1: one releaseDigest, two manifest digests. + const manifests: StatusVectors["manifests"] = []; + for (const [name, base] of [ + [V120, RELEASE_MANIFEST_FIXTURE], + [V121, { ...RELEASE_MANIFEST_FIXTURE, version: "1.2.1" }], + [MINIMAL, RELEASE_MANIFEST_MINIMAL_FIXTURE], + ] as const) { + const manifest = await signReleaseManifest(base, key.privateKey); + manifests.push({ name, document: canonicalJson(manifest), manifestDigest: await releaseManifestDigest(manifest) }); + } + const nextVersionDigest = manifests[1]!.manifestDigest; + + const published = await sign({ ...RELEASE_STATUS_PUBLISHED_FIXTURE }); + const yanked = await sign({ ...RELEASE_STATUS_YANKED_FIXTURE }); + const quarantined = await yankedWith({ state: "quarantined" }); + const doc = (status: SkillxReleaseStatus) => canonicalJson(status); + const yankedDoc = doc(yanked); + + const valid: StatusVectors["valid"] = []; + const validCases: Array<[string, SkillxReleaseStatus, SkillxReleaseStatus | null, string, string]> = [ + ["first status of a release", published, null, V120, DAY_ONE], + ["yanked with a reason after the published status", yanked, published, V120, DAY_TWO], + ["identical re-fetch of the accepted status", yanked, yanked, V120, DAY_TWO], + [ + "first status of another version of the same tree, tracked separately", + await publishedWith({ manifestDigest: nextVersionDigest }), + null, + V121, + DAY_ONE, + ], + ["daily re-sign of the same state", await yankedWith({ sequence: 3, ...NEXT_DAY }), yanked, V120, DAY_THREE], + ["yanked release published again", await yankedWith({ state: "published", sequence: 3, ...NEXT_DAY }), yanked, V120, DAY_THREE], + [ + "quarantined to published across a skipped sequence (via yanked)", + await yankedWith({ state: "published", sequence: 4, ...NEXT_DAY }), + quarantined, + V120, + DAY_THREE, + ], + ["checked exactly at expiresAt", published, null, V120, RELEASE_STATUS_PUBLISHED_FIXTURE.expiresAt], + ["issuedAt exactly at the 300 second clock-skew limit", published, null, V120, SKEW_EDGE], + ]; + for (const [name, status, previous, manifest, now] of validCases) { + const payload = releaseStatusSigningPayload(status); + valid.push({ + name, + manifest, + now, + previous: previous === null ? null : doc(previous), + document: doc(status), + signingPayload: decoder.decode(payload), + signingPayloadSha256: hex(await sha256(payload)), + }); + } + + const tamper = (mutate: (s: SkillxReleaseStatus) => void): string => { + const copy = clone(yanked); + mutate(copy); + return doc(copy); + }; + const goStyleSignature = base64( + new Uint8Array( + await crypto.subtle.sign({ name: "Ed25519" }, key.privateKey, goJsonStyle(releaseStatusSigningPayload(RELEASE_STATUS_YANKED_FIXTURE))), + ), + ); + const { manifestDigest: _m, ...withoutManifestDigest } = RELEASE_STATUS_PUBLISHED_FIXTURE; + const { expiresAt: _e, ...withoutExpiresAt } = RELEASE_STATUS_PUBLISHED_FIXTURE; + const mismatch = RELEASE_SIGNATURE_CODES.mismatch; + const C = RELEASE_STATUS_CODES; + + const invalid: StatusVectors["invalid"] = []; + const add = ( + name: string, + document: string, + expectedCode: string, + options: { previous?: SkillxReleaseStatus | null; manifest?: string; now?: string } = {}, + ) => + invalid.push({ + name, + manifest: options.manifest ?? V120, + now: options.now ?? DAY_TWO, + previous: options.previous ? doc(options.previous) : null, + document, + expectedCode, + }); + + // Signature. + add("state changed after signing", tamper((s) => void (s.state = "published")), mismatch, { previous: published }); + add("reason changed after signing", tamper((s) => void (s.reason = `${RELEASE_STATUS_FIXTURE_REASON}.`)), mismatch, { previous: published }); + add("sequence raised after signing", tamper((s) => void (s.sequence = 3)), mismatch, { previous: yanked }); + add("expiry extended after signing", tamper((s) => void (s.expiresAt = "2026-10-21T09:00:00Z")), mismatch); + add("signed by another key under the trusted key ID", doc(await signRaw({ ...RELEASE_STATUS_YANKED_FIXTURE }, other)), mismatch, { + previous: published, + }); + add("unknown key ID", tamper((s) => void (s.keyId = "skillx-unknown-key")), RELEASE_SIGNATURE_CODES.unknownKey, { previous: published }); + add("signature over an encoding/json-escaped payload", tamper((s) => void (s.signature = goStyleSignature)), mismatch, { previous: published }); + + // Received bytes. + add("pretty-printed", JSON.stringify(yanked, null, 2), C.nonCanonical); + add("members in insertion order", JSON.stringify(yanked), C.nonCanonical); + add("duplicate member", yankedDoc.replace("{", '{"state":"published",'), C.nonCanonical); + add("exponent number form", yankedDoc.replace('"sequence":2', '"sequence":2e0'), C.nonCanonical); + add("escaped ASCII character", yankedDoc.replace("ed25519", "ed2551\\u0039"), C.nonCanonical); + add("byte order mark", `${String.fromCodePoint(0xfeff)}${yankedDoc}`, C.nonCanonical); + add("member differing from a known one only by case, correctly signed", doc(await yankedWith({ State: "published" })), "unknown_field"); + add("empty reason injected, correctly signed", doc(await publishedWith({ reason: "" })), "length", { now: DAY_ONE }); + add("null reason injected, correctly signed", doc(await publishedWith({ reason: null })), "type", { now: DAY_ONE }); + + // Structure. + add("unknown state, correctly signed", doc(await publishedWith({ state: "revoked", sequence: 3 })), C.unknownState, { previous: yanked }); + add("missing manifestDigest, correctly signed", doc(await sign(withoutManifestDigest)), "required", { now: DAY_ONE }); + add("missing expiresAt, correctly signed", doc(await sign(withoutExpiresAt)), "required", { now: DAY_ONE }); + add("expiresAt before issuedAt, correctly signed", doc(await publishedWith({ expiresAt: "2026-10-13T04:30:59Z" })), C.invalidLifetime, { + now: DAY_ONE, + }); + add("lifetime longer than 7 days, correctly signed", doc(await publishedWith({ expiresAt: "2026-10-20T04:31:01Z" })), C.invalidLifetime, { + now: DAY_ONE, + }); + add("first status that is yanked, correctly signed", doc(await yankedWith({ sequence: 1 })), C.invalidTransition); + add( + "reason over 512 UTF-8 bytes, correctly signed", + doc(await yankedWith({ reason: String.fromCodePoint(0xe9).repeat(RELEASE_STATUS_REASON_MAX_BYTES / 2) + "." })), + "length", + ); + add( + "bidi override in the reason, correctly signed", + doc(await yankedWith({ reason: `see ${String.fromCodePoint(0x202e)}hs.kcehc` })), + "format", + ); + + // Binding and freshness. + add( + "status of another release tree", + doc( + await publishedWith({ + releaseDigest: RELEASE_MANIFEST_MINIMAL_FIXTURE.releaseDigest, + manifestDigest: RELEASE_MINIMAL_FIXTURE_MANIFEST_DIGEST, + }), + ), + C.digestMismatch, + { now: DAY_ONE }, + ); + add("status of another version of the same tree", doc(await yankedWith({ manifestDigest: nextVersionDigest })), C.manifestMismatch, { + previous: published, + }); + add("wrong manifestDigest, correctly signed", doc(await publishedWith({ manifestDigest: `sha256:${"e".repeat(64)}` })), C.manifestMismatch, { + now: DAY_ONE, + }); + add( + "issued before the manifest was created", + doc(await publishedWith({ issuedAt: "2026-10-13T04:29:59Z", expiresAt: "2026-10-14T04:29:59Z" })), + C.issuedBeforeRelease, + { now: DAY_ONE }, + ); + add("expired one second after expiresAt", doc(published), C.expired, { now: "2026-10-14T04:31:01Z" }); + add("issuedAt 301 seconds ahead of the consumer's clock", doc(published), C.notYetValid, { now: "2026-10-13T04:25:59Z" }); + add( + "signature with non-zero unused base64 bits (same bytes for a lenient decoder)", + tamper((s) => void (s.signature = withNonZeroPaddingBits(s.signature))), + "pattern", + ); + + // Against the previous accepted status. + add("lower sequence than the accepted status (rollback)", doc(published), C.rollback, { previous: yanked, now: DAY_ONE }); + add("same sequence with different content (conflict)", doc(await yankedWith({ state: "published" })), C.conflict, { previous: yanked }); + add("higher sequence issued no later (issuedAt regression)", doc(await yankedWith({ sequence: 3 })), C.issuedAtRegression, { + previous: yanked, + }); + add("yanked back to draft", doc(await yankedWith({ state: "draft", sequence: 3, ...NEXT_DAY })), C.invalidTransition, { + previous: yanked, + now: DAY_THREE, + }); + add( + "quarantined straight to published", + doc(await yankedWith({ state: "published", sequence: 3, ...NEXT_DAY })), + C.invalidTransition, + { previous: quarantined, now: DAY_THREE }, + ); + + return { + description: + "skillx.release-status/v1 signed status vectors. The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + canonicalization: + "same as skillx.release/v1: padded base64 Ed25519 over the UTF-8 canonical JSON of the status without its signature member; an absent reason is omitted, never null or empty; a served document is exactly the canonical JSON including signature", + manifestDigestRule: + "manifestDigest = 'sha256:' + lowercase hex SHA-256 of the manifest signing payload: the UTF-8 canonical JSON of the skillx.release/v1 manifest without its signature member (the exact bytes its signature covers)", + consumerRules: [ + "verify the manifest document first (skillx.release/v1 vectors); statuses are checked against that verified manifest", + "re-encode the received document canonically; any byte difference is status.non_canonical", + "validate the structure: unknown member unknown_field; missing member required; state outside draft|quarantined|published|yanked status.unknown_state even when signed; reason 1..512 UTF-8 bytes (length, null is type) without Cc, Cf (incl. bidi controls, Unicode 15.0 tables), Zl, Zp or Cs code points (format); signature matching ^[A-Za-z0-9+/]{85}[AQgw]==$ else pattern; issuedAt < expiresAt <= issuedAt + 7 days else status.invalid_lifetime; sequence 1 must be draft or published else status.invalid_transition", + "require releaseDigest to equal the verified manifest's releaseDigest, else status.digest_mismatch", + "require manifestDigest to equal the verified manifest's manifest digest, else status.manifest_mismatch", + "require issuedAt >= the manifest createdAt, else status.issued_before_release", + "verify the signature with the trusted key for keyId: signature.unknown_key, signature.invalid_key or signature.mismatch", + "require issuedAt <= now + 300 s, else status.not_yet_valid", + "require now <= expiresAt, else status.expired; an expired status may only keep already-installed content, never authorize a new resolution or install", + "per manifestDigest, against the last accepted status: a lower sequence is status.rollback; the same sequence with different signed bytes is status.conflict (identical bytes are accepted); a higher sequence needs a later issuedAt (status.issued_at_regression) and a state reachable in at most (sequence gap) steps of draft->draft|published, published->published|yanked|quarantined, yanked->yanked|published, quarantined->quarantined|yanked (status.invalid_transition)", + ], + testKey: describeVectorKey(RELEASE_FIXTURE_KEY_ID, VECTOR_KEY_SEED_TEXT, key), + manifests, + valid, + invalid, + }; +} + +const committed = vectorFile as unknown as StatusVectors; + +function trustedKeys(): Map { + return new Map([[committed.testKey.keyId, fromBase64(committed.testKey.publicKeyBase64)]]); +} + +function verifyVector(vector: StatusVector) { + const entry = committed.manifests.find((m) => m.name === vector.manifest); + if (entry === undefined) throw new Error(`unknown manifest ${vector.manifest}`); + return verifyReleaseStatus(vector.document, trustedKeys(), { + manifest: JSON.parse(entry.document) as SkillxReleaseManifest, + now: new Date(vector.now), + previous: vector.previous === null ? null : (JSON.parse(vector.previous) as SkillxReleaseStatus), + }); +} + +describe("skillx.release-status/v1 cross-language vectors", () => { + it.runIf(writeVectorsRequested)("writes the vector file", async () => { + await writeVectorFile("release-status-v1.json", await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("match the committed vector file byte for byte in content", async () => { + expect(committed).toEqual(await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("list verified manifests with their manifest digests", async () => { + expect(committed.manifests.length).toBe(3); + const [v120, v121] = committed.manifests.map((m) => JSON.parse(m.document) as SkillxReleaseManifest); + expect(v120!.releaseDigest).toBe(v121!.releaseDigest); + expect(committed.manifests[0]!.manifestDigest).toBe(RELEASE_FIXTURE_MANIFEST_DIGEST); + expect(committed.manifests[0]!.manifestDigest).not.toBe(committed.manifests[1]!.manifestDigest); + for (const entry of committed.manifests) { + expect(await verifyReleaseManifest(entry.document, trustedKeys()), entry.name).toEqual({ valid: true, issues: [] }); + expect(await releaseManifestDigest(JSON.parse(entry.document) as SkillxReleaseManifest), entry.name).toBe(entry.manifestDigest); + } + }); + + it.skipIf(writeVectorsRequested)("accept every valid vector", async () => { + expect(committed.valid.length).toBe(9); + for (const vector of committed.valid) { + expect(await verifyVector(vector), vector.name).toEqual({ valid: true, issues: [] }); + const status = JSON.parse(vector.document) as SkillxReleaseStatus; + expect(decoder.decode(releaseStatusSigningPayload(status))).toBe(vector.signingPayload); + } + }); + + it.skipIf(writeVectorsRequested)("reject every invalid vector with exactly its code", async () => { + const codes = new Set(); + for (const vector of committed.invalid) { + expect((await verifyVector(vector)).issues.map((i) => i.code), vector.name).toEqual([vector.expectedCode]); + codes.add(vector.expectedCode); + } + expect([...codes].sort()).toEqual( + [ + ...Object.values(RELEASE_STATUS_CODES), + RELEASE_SIGNATURE_CODES.mismatch, + RELEASE_SIGNATURE_CODES.unknownKey, + "required", + "unknown_field", + "length", + "type", + "format", + "pattern", + ].sort(), + ); + }); + + it.skipIf(writeVectorsRequested)("bounds every status lifetime by 7 days", () => { + for (const vector of committed.valid) { + const status = JSON.parse(vector.document) as SkillxReleaseStatus; + const lifetime = (Date.parse(status.expiresAt) - Date.parse(status.issuedAt)) / 1000; + expect(lifetime).toBeGreaterThan(0); + expect(lifetime).toBeLessThanOrEqual(RELEASE_STATUS_MAX_LIFETIME_SECONDS); + } + }); + + it.skipIf(writeVectorsRequested)("signs the reason with raw non-ASCII and HTML characters", () => { + const yanked = committed.valid[1]!; + expect(yanked.signingPayload).toContain(RELEASE_STATUS_FIXTURE_REASON); + expect(yanked.signingPayload).not.toContain("\\u00"); + }); +}); diff --git a/packages/contracts/src/release-status.test.ts b/packages/contracts/src/release-status.test.ts new file mode 100644 index 0000000..6c5b693 --- /dev/null +++ b/packages/contracts/src/release-status.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, it } from "vitest"; +import { UNICODE_VERSION } from "@skillx/skill-tree/rules"; +import { canonicalJson } from "./canonical-json"; +import { RELEASE_FIXTURE_KEY_ID, RELEASE_MANIFEST_FIXTURE, RELEASE_MANIFEST_MINIMAL_FIXTURE } from "./fixtures/release-fixtures"; +import { + RELEASE_FIXTURE_MANIFEST_DIGEST, + RELEASE_MINIMAL_FIXTURE_MANIFEST_DIGEST, + RELEASE_STATUS_PUBLISHED_FIXTURE, + RELEASE_STATUS_YANKED_FIXTURE, +} from "./fixtures/release-status-fixtures"; +import { RELEASE_SIGNATURE_CODES, ReleaseManifestError, releaseManifestDigest, signReleaseManifest } from "./release-manifest"; +import type { SkillxReleaseManifest } from "./release-manifest"; +import { + RELEASE_STATE_TRANSITIONS, + RELEASE_STATUS_CODES, + RELEASE_STATUS_REASON_MAX_BYTES, + isReachableState, + releaseStatusSigningPayload, + signReleaseStatus, + validateReleaseStatus, + validateUnsignedReleaseStatus, + verifyReleaseStatus, +} from "./release-status"; +import type { ReleaseStatusVerifyOptions, SkillxReleaseStatus, UnsignedReleaseStatus } from "./release-status"; + +const decoder = new TextDecoder(); +const DIGEST = RELEASE_MANIFEST_FIXTURE.releaseDigest; +/** Inside the lifetime of the published fixture (issued 2026-10-13T04:31:00Z). */ +const DAY_ONE = new Date("2026-10-13T12:00:00Z"); +/** Inside the lifetime of the yanked fixture (issued 2026-10-14T09:00:00Z). */ +const DAY_TWO = new Date("2026-10-14T12:00:00Z"); + +async function generateKey(): Promise<{ privateKey: CryptoKey; publicKey: Uint8Array }> { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"])) as CryptoKeyPair; + return { privateKey: pair.privateKey, publicKey: new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)) }; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +const doc = (value: unknown): string => canonicalJson(value); + +async function setup() { + const { privateKey, publicKey } = await generateKey(); + const keys = new Map([[RELEASE_FIXTURE_KEY_ID, publicKey]]); + const manifest = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, privateKey); + const published = await signReleaseStatus(RELEASE_STATUS_PUBLISHED_FIXTURE, privateKey); + const yanked = await signReleaseStatus(RELEASE_STATUS_YANKED_FIXTURE, privateKey); + const sign = (over: Partial) => signReleaseStatus({ ...RELEASE_STATUS_YANKED_FIXTURE, ...over }, privateKey); + const at = (now: Date, extra: Partial = {}): ReleaseStatusVerifyOptions => ({ manifest, now, ...extra }); + return { privateKey, keys, manifest, published, yanked, sign, at }; +} + +/** A later status of the fixture release, `days` after the yanked one. */ +function laterTimes(days: number): Pick { + const issued = Date.parse(RELEASE_STATUS_YANKED_FIXTURE.issuedAt) + days * 86_400_000; + const iso = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z"); + return { issuedAt: iso(issued), expiresAt: iso(issued + 86_400_000) }; +} + +const codes = (result: { issues: { code: string }[] }) => result.issues.map((i) => i.code); +const OK = { valid: true, issues: [] }; + +describe("release manifest digest", () => { + it("is the sha256 of the manifest signing payload and matches the fixture constants", async () => { + expect(await releaseManifestDigest(RELEASE_MANIFEST_FIXTURE)).toBe(RELEASE_FIXTURE_MANIFEST_DIGEST); + expect(await releaseManifestDigest(RELEASE_MANIFEST_MINIMAL_FIXTURE)).toBe(RELEASE_MINIMAL_FIXTURE_MANIFEST_DIGEST); + }); + + it("ignores the signature but distinguishes two versions of the same tree", async () => { + const { manifest } = await setup(); + expect(await releaseManifestDigest(manifest)).toBe(RELEASE_FIXTURE_MANIFEST_DIGEST); + const nextVersion = { ...RELEASE_MANIFEST_FIXTURE, version: "1.2.1" }; + expect(nextVersion.releaseDigest).toBe(RELEASE_MANIFEST_FIXTURE.releaseDigest); + expect(await releaseManifestDigest(nextVersion)).not.toBe(RELEASE_FIXTURE_MANIFEST_DIGEST); + }); +}); + +describe("skillx.release-status/v1 validation", () => { + it("accepts both unsigned fixtures before signing", () => { + expect(validateUnsignedReleaseStatus(RELEASE_STATUS_PUBLISHED_FIXTURE)).toEqual(OK); + expect(validateUnsignedReleaseStatus(RELEASE_STATUS_YANKED_FIXTURE)).toEqual(OK); + }); + + it("rejects any non-document input without throwing", () => { + class Status { + state = "published"; + } + // eslint-disable-next-line no-sparse-arrays + const sparse = [, 1]; + const inputs: unknown[] = [ + null, + undefined, + "published", + [], + 1, + new Date(0), + new Map([["state", "published"]]), + new Status(), + Object.create(null), + { ...RELEASE_STATUS_PUBLISHED_FIXTURE, sequence: 1n }, + { ...RELEASE_STATUS_PUBLISHED_FIXTURE, sequence: Number.NaN }, + { ...RELEASE_STATUS_PUBLISHED_FIXTURE, sequence: -0 }, + { ...RELEASE_STATUS_PUBLISHED_FIXTURE, extra: sparse }, + { ...RELEASE_STATUS_PUBLISHED_FIXTURE, extra: () => 1 }, + ]; + for (const input of inputs) { + expect(() => validateReleaseStatus(input)).not.toThrow(); + expect(validateReleaseStatus(input).valid).toBe(false); + } + }); + + it("reports every required member of an empty object, but not the optional reason", () => { + const result = validateReleaseStatus({}); + const paths = result.issues.filter((i) => i.code === "required").map((i) => i.path); + expect(paths.sort()).toEqual( + [ + "expiresAt", + "issuedAt", + "keyId", + "manifestDigest", + "releaseDigest", + "schemaVersion", + "sequence", + "signature", + "signatureAlgorithm", + "state", + ].sort(), + ); + expect(result.issues.filter((i) => i.path === "manifestDigest").map((i) => i.code)).toEqual(["required"]); + }); + + it("rejects an undefined reason member instead of failing to canonicalize", () => { + const status = { ...RELEASE_STATUS_PUBLISHED_FIXTURE, reason: undefined }; + expect(codes(validateUnsignedReleaseStatus(status))).toEqual(["type"]); + }); + + it("bounds the reason in UTF-8 bytes, not UTF-16 code units", () => { + const at = (reason: string) => ({ ...RELEASE_STATUS_YANKED_FIXTURE, reason }); + expect(validateUnsignedReleaseStatus(at("x".repeat(RELEASE_STATUS_REASON_MAX_BYTES))).valid).toBe(true); + expect(codes(validateUnsignedReleaseStatus(at("x".repeat(RELEASE_STATUS_REASON_MAX_BYTES + 1))))).toEqual(["length"]); + // 171 code points of 3 UTF-8 bytes each: 513 bytes in 171 UTF-16 units. + expect(codes(validateUnsignedReleaseStatus(at(String.fromCodePoint(0x20ac).repeat(171))))).toEqual(["length"]); + }); + + it.each([ + ["a tab", 0x09], + ["DEL", 0x7f], + ["a C1 control", 0x85], + ["a right-to-left override", 0x202e], + ["a left-to-right isolate", 0x2066], + ["an Arabic letter mark", 0x061c], + ["a byte order mark", 0xfeff], + ["a paragraph separator", 0x2029], + ])("rejects %s in the reason", (_name, codePoint) => { + const status = { ...RELEASE_STATUS_YANKED_FIXTURE, reason: `bad ${String.fromCodePoint(codePoint)} reason` }; + expect(codes(validateUnsignedReleaseStatus(status))).toEqual(["format"]); + }); + + it("classifies format characters with the pinned Unicode 15.0 tables", () => { + expect(UNICODE_VERSION).toBe("15.0.0"); + // U+13430 EGYPTIAN HIEROGLYPH VERTICAL JOINER is Cf in Unicode 15.0. + const status = { ...RELEASE_STATUS_YANKED_FIXTURE, reason: `x${String.fromCodePoint(0x13430)}y` }; + expect(codes(validateUnsignedReleaseStatus(status))).toEqual(["format"]); + }); + + it("accepts non-ASCII letters and emoji in the reason", () => { + const status = { ...RELEASE_STATUS_YANKED_FIXTURE, reason: `caf${String.fromCodePoint(0xe9)} ${String.fromCodePoint(0x1f600)}` }; + expect(validateUnsignedReleaseStatus(status)).toEqual(OK); + }); + + it("rejects a lone surrogate in the reason", () => { + const status = { ...RELEASE_STATUS_YANKED_FIXTURE, reason: `bad ${String.fromCharCode(0xd800)}` }; + expect(validateUnsignedReleaseStatus(status).valid).toBe(false); + }); + + it("rejects an unsafe sequence", () => { + const status = { ...RELEASE_STATUS_PUBLISHED_FIXTURE, sequence: Number.MAX_SAFE_INTEGER + 1 }; + expect(codes(validateUnsignedReleaseStatus(status))).toEqual(["type"]); + }); + + it("allows only draft or published as the first status", () => { + for (const state of ["draft", "published"] as const) { + expect(validateUnsignedReleaseStatus({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, state }).valid).toBe(true); + } + for (const state of ["yanked", "quarantined"] as const) { + expect(codes(validateUnsignedReleaseStatus({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, state }))).toEqual([ + RELEASE_STATUS_CODES.invalidTransition, + ]); + } + }); + + it("bounds the lifetime to more than zero and at most 7 days", () => { + const at = (expiresAt: string) => ({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, expiresAt }); + expect(validateUnsignedReleaseStatus(at("2026-10-13T04:31:01Z")).valid).toBe(true); + expect(validateUnsignedReleaseStatus(at("2026-10-20T04:31:00Z")).valid).toBe(true); + for (const expiresAt of ["2026-10-13T04:31:00Z", "2026-10-13T04:30:59Z", "2026-10-20T04:31:01Z"]) { + expect(codes(validateUnsignedReleaseStatus(at(expiresAt)))).toEqual([RELEASE_STATUS_CODES.invalidLifetime]); + } + expect(codes(validateUnsignedReleaseStatus(at("2026-10-14")))).toEqual(["format"]); + }); +}); + +describe("release state transitions", () => { + it("matches the documented graph", () => { + expect(RELEASE_STATE_TRANSITIONS).toEqual({ + draft: ["draft", "published"], + published: ["published", "yanked", "quarantined"], + yanked: ["yanked", "published"], + quarantined: ["quarantined", "yanked"], + }); + }); + + it("never returns to draft and reaches states through skipped sequences", () => { + for (const from of ["published", "yanked", "quarantined"] as const) expect(isReachableState(from, "draft", 100)).toBe(false); + expect(isReachableState("quarantined", "published", 1)).toBe(false); + expect(isReachableState("quarantined", "published", 2)).toBe(true); + expect(isReachableState("draft", "quarantined", 1)).toBe(false); + expect(isReachableState("draft", "quarantined", 2)).toBe(true); + expect(isReachableState("published", "published", 1)).toBe(true); + }); +}); + +describe("skillx.release-status/v1 signing payload", () => { + it("is byte-stable across member order and omits an absent reason", () => { + const reordered = Object.fromEntries(Object.entries(RELEASE_STATUS_PUBLISHED_FIXTURE).reverse()) as unknown as UnsignedReleaseStatus; + expect(releaseStatusSigningPayload(reordered)).toEqual(releaseStatusSigningPayload(RELEASE_STATUS_PUBLISHED_FIXTURE)); + const text = decoder.decode(releaseStatusSigningPayload(RELEASE_STATUS_PUBLISHED_FIXTURE)); + expect(text).toBe( + `{"expiresAt":"2026-10-14T04:31:00Z","issuedAt":"2026-10-13T04:31:00Z","keyId":"skillx-test-vector-1","manifestDigest":"${RELEASE_FIXTURE_MANIFEST_DIGEST}","releaseDigest":"${DIGEST}","schemaVersion":"skillx.release-status/v1","sequence":1,"signatureAlgorithm":"ed25519","state":"published"}`, + ); + }); +}); + +describe("skillx.release-status/v1 verification", () => { + it("verifies a first status and a later one against the accepted status, from text or bytes", async () => { + const { keys, published, yanked, at } = await setup(); + expect(await verifyReleaseStatus(doc(published), keys, at(DAY_ONE))).toEqual(OK); + expect(await verifyReleaseStatus(new TextEncoder().encode(doc(published)), keys, at(DAY_ONE))).toEqual(OK); + expect(await verifyReleaseStatus(doc(yanked), keys, at(DAY_TWO, { previous: published }))).toEqual(OK); + expect(await verifyReleaseStatus(doc(yanked), keys, at(DAY_TWO, { previous: yanked }))).toEqual(OK); + }); + + describe("received bytes", () => { + it.each<[string, (text: string) => string | Uint8Array]>([ + ["pretty-printed", (t) => JSON.stringify(JSON.parse(t), null, 2)], + ["a trailing newline", (t) => `${t}\n`], + ["a byte order mark", (t) => `${String.fromCodePoint(0xfeff)}${t}`], + ["reordered members", (t) => JSON.stringify(Object.fromEntries(Object.entries(JSON.parse(t) as object).reverse()))], + ["a duplicate member", (t) => t.replace("{", '{"state":"published",')], + ["an exponent number form", (t) => t.replace('"sequence":2', '"sequence":2e0')], + ["an escaped ASCII character", (t) => t.replace("ed25519", "ed2551\\u0039")], + ["invalid UTF-8", (t) => new Uint8Array([...new TextEncoder().encode(t).slice(0, -1), 0xff, 0x7d])], + ["not JSON", () => "{"], + ])("rejects %s as non-canonical", async (_name, mangle) => { + const { keys, yanked, at } = await setup(); + expect(codes(await verifyReleaseStatus(mangle(doc(yanked)), keys, at(DAY_TWO)))).toEqual([RELEASE_STATUS_CODES.nonCanonical]); + }); + + it("rejects a canonical member that differs from a known one only by case", async () => { + const { keys, yanked, at } = await setup(); + const text = doc({ ...yanked, State: "published" }); + expect(codes(await verifyReleaseStatus(text, keys, at(DAY_TWO)))).toEqual(["unknown_field"]); + }); + + it.each([ + ["an empty", ""], + ["a null", null], + ])("rejects %s optional reason injected into a status without one", async (_name, reason) => { + const { keys, published, at } = await setup(); + const result = await verifyReleaseStatus(doc({ ...published, reason }), keys, at(DAY_ONE)); + expect(result.valid).toBe(false); + expect(result.issues.map((i) => i.path)).toEqual(["reason"]); + }); + }); + + it.each<[string, (s: SkillxReleaseStatus) => void]>([ + ["a changed state", (s) => void (s.state = "published")], + ["a changed reason", (s) => void (s.reason = "other")], + ["a removed reason", (s) => void delete s.reason], + ["a raised sequence", (s) => void (s.sequence = 9)], + ["a changed issue time", (s) => void (s.issuedAt = "2026-10-14T10:00:00Z")], + ["an extended expiry", (s) => void (s.expiresAt = "2026-10-21T09:00:00Z")], + ])("rejects %s", async (_name, tamper) => { + const { keys, yanked, at } = await setup(); + const tampered = clone(yanked); + tamper(tampered); + expect(codes(await verifyReleaseStatus(doc(tampered), keys, at(DAY_TWO)))).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + + it("rejects a manifestDigest changed after signing", async () => { + const { privateKey, keys, yanked, at } = await setup(); + const otherManifest = await signReleaseManifest({ ...RELEASE_MANIFEST_FIXTURE, version: "1.2.1" }, privateKey); + const tampered = { ...clone(yanked), manifestDigest: await releaseManifestDigest(otherManifest) }; + expect(codes(await verifyReleaseStatus(doc(tampered), keys, at(DAY_TWO, { manifest: otherManifest })))).toEqual([ + RELEASE_SIGNATURE_CODES.mismatch, + ]); + }); + + it("rejects a status signed by another key under the trusted key ID", async () => { + const { yanked, at } = await setup(); + const trusted = await generateKey(); + const result = await verifyReleaseStatus(doc(yanked), new Map([[RELEASE_FIXTURE_KEY_ID, trusted.publicKey]]), at(DAY_TWO)); + expect(codes(result)).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + + it("rejects an unknown key ID", async () => { + const { yanked, keys, at } = await setup(); + const result = await verifyReleaseStatus(doc(yanked), new Map([["skillx-other-key", keys.get(RELEASE_FIXTURE_KEY_ID)!]]), at(DAY_TWO)); + expect(codes(result)).toEqual([RELEASE_SIGNATURE_CODES.unknownKey]); + }); + + describe("expiry", () => { + it("accepts a status up to and including expiresAt", async () => { + const { keys, published, at } = await setup(); + expect(await verifyReleaseStatus(doc(published), keys, at(new Date("2026-10-14T04:31:00Z")))).toEqual(OK); + }); + + it("rejects a status after expiresAt, but only once the signature verifies", async () => { + const { keys, published, at } = await setup(); + const late = at(new Date("2026-10-14T04:31:01Z")); + expect(codes(await verifyReleaseStatus(doc(published), keys, late))).toEqual([RELEASE_STATUS_CODES.expired]); + const forged = { ...clone(published), state: "draft" }; + expect(codes(await verifyReleaseStatus(doc(forged), keys, late))).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + }); + + describe("clock skew", () => { + it("accepts an issuedAt up to 300 seconds ahead of now", async () => { + const { keys, published, at } = await setup(); + expect(await verifyReleaseStatus(doc(published), keys, at(new Date("2026-10-13T04:26:00Z")))).toEqual(OK); + }); + + it("rejects an issuedAt further ahead, but only once the signature verifies", async () => { + const { keys, published, at } = await setup(); + const early = at(new Date("2026-10-13T04:25:59Z")); + expect(codes(await verifyReleaseStatus(doc(published), keys, early))).toEqual([RELEASE_STATUS_CODES.notYetValid]); + const forged = { ...clone(published), state: "draft" }; + expect(codes(await verifyReleaseStatus(doc(forged), keys, early))).toEqual([RELEASE_SIGNATURE_CODES.mismatch]); + }); + }); + + it("rejects a status issued before the release manifest was created", async () => { + const { keys, sign, at } = await setup(); + const early = await sign({ state: "published", sequence: 1, issuedAt: "2026-10-13T04:29:59Z", expiresAt: "2026-10-14T04:29:59Z" }); + expect(codes(await verifyReleaseStatus(doc(early), keys, at(DAY_ONE)))).toEqual([RELEASE_STATUS_CODES.issuedBeforeRelease]); + }); + + describe("against the previous accepted status", () => { + it("rejects a lower sequence", async () => { + const { keys, published, yanked, at } = await setup(); + expect(codes(await verifyReleaseStatus(doc(published), keys, at(DAY_ONE, { previous: yanked })))).toEqual([ + RELEASE_STATUS_CODES.rollback, + ]); + }); + + it("rejects a different status with the accepted sequence", async () => { + const { keys, yanked, sign, at } = await setup(); + const rival = await sign({ state: "published" }); + expect(codes(await verifyReleaseStatus(doc(rival), keys, at(DAY_TWO, { previous: yanked })))).toEqual([ + RELEASE_STATUS_CODES.conflict, + ]); + }); + + it("rejects a higher sequence that is not issued later", async () => { + const { keys, yanked, sign, at } = await setup(); + const stale = await sign({ sequence: 3 }); + expect(codes(await verifyReleaseStatus(doc(stale), keys, at(DAY_TWO, { previous: yanked })))).toEqual([ + RELEASE_STATUS_CODES.issuedAtRegression, + ]); + }); + + it("accepts a daily re-sign of the same state", async () => { + const { keys, yanked, sign, at } = await setup(); + const refreshed = await sign({ sequence: 3, ...laterTimes(1) }); + expect(await verifyReleaseStatus(doc(refreshed), keys, at(new Date("2026-10-15T12:00:00Z"), { previous: yanked }))).toEqual(OK); + }); + + it.each<[string, "draft" | "published" | "yanked" | "quarantined", "draft" | "published" | "yanked" | "quarantined", number, boolean]>([ + ["yanked to draft", "yanked", "draft", 1, false], + ["yanked to quarantined", "yanked", "quarantined", 1, false], + ["yanked to published", "yanked", "published", 1, true], + ["yanked to quarantined across a skipped sequence", "yanked", "quarantined", 2, true], + ["quarantined to published", "quarantined", "published", 1, false], + ["quarantined to published across a skipped sequence", "quarantined", "published", 2, true], + ["quarantined to draft across many skipped sequences", "quarantined", "draft", 50, false], + ])("%s", async (_name, from, to, gap, allowed) => { + const { keys, sign, at } = await setup(); + const previous = await sign({ state: from }); + const next = await sign({ state: to, sequence: 2 + gap, ...laterTimes(1) }); + const result = await verifyReleaseStatus(doc(next), keys, at(new Date("2026-10-15T12:00:00Z"), { previous })); + expect(result).toEqual(allowed ? OK : { valid: false, issues: [expect.objectContaining({ code: RELEASE_STATUS_CODES.invalidTransition })] }); + }); + }); + + it("rejects a status of another release tree", async () => { + const { privateKey, keys, published } = await setup(); + const minimal = await signReleaseManifest(RELEASE_MANIFEST_MINIMAL_FIXTURE, privateKey); + const result = await verifyReleaseStatus(doc(published), keys, { manifest: minimal, now: DAY_ONE }); + expect(codes(result)).toEqual([RELEASE_STATUS_CODES.digestMismatch]); + }); + + it("rejects a status of another version of the same tree", async () => { + const { privateKey, keys, published, at, sign } = await setup(); + const otherManifest = await signReleaseManifest({ ...RELEASE_MANIFEST_FIXTURE, version: "1.2.1" }, privateKey); + const otherVersion = await releaseManifestDigest(otherManifest); + // A status for 1.2.1 does not apply to 1.2.0, although both share the tree digest. + const forOther = await sign({ manifestDigest: otherVersion }); + expect(codes(await verifyReleaseStatus(doc(forOther), keys, at(DAY_TWO)))).toEqual([RELEASE_STATUS_CODES.manifestMismatch]); + // Sequences are tracked per manifest: 1.2.1's status at sequence 1 is not a rollback of 1.2.0's sequence 2. + const firstForOther = await sign({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, manifestDigest: otherVersion }); + expect(await verifyReleaseStatus(doc(firstForOther), keys, at(DAY_ONE, { manifest: otherManifest }))).toEqual(OK); + expect(published.manifestDigest).not.toBe(otherVersion); + }); + + it("fails closed on an unknown state before checking the signature", async () => { + const { keys, published, at } = await setup(); + const unknown = { ...clone(published), state: "revoked" }; + expect(codes(await verifyReleaseStatus(doc(unknown), keys, at(DAY_ONE)))).toEqual([RELEASE_STATUS_CODES.unknownState]); + }); + + it("throws on invalid caller options", async () => { + const { privateKey, keys, yanked, at, sign, manifest } = await setup(); + const otherManifest = await signReleaseManifest({ ...RELEASE_MANIFEST_FIXTURE, version: "1.2.1" }, privateKey); + const foreign = await sign({ ...RELEASE_STATUS_PUBLISHED_FIXTURE, manifestDigest: await releaseManifestDigest(otherManifest) }); + await expect(verifyReleaseStatus(doc(yanked), keys, at(DAY_TWO, { previous: foreign }))).rejects.toBeInstanceOf(ReleaseManifestError); + await expect(verifyReleaseStatus(doc(yanked), keys, at(new Date(Number.NaN)))).rejects.toBeInstanceOf(ReleaseManifestError); + const broken = { ...manifest, fileCount: 9 } as SkillxReleaseManifest; + await expect(verifyReleaseStatus(doc(yanked), keys, at(DAY_TWO, { manifest: broken }))).rejects.toBeInstanceOf(ReleaseManifestError); + }); + + it("refuses to sign an unknown state or an invalid lifetime", async () => { + const { privateKey } = await generateKey(); + const unknown = { ...RELEASE_STATUS_PUBLISHED_FIXTURE, state: "revoked" } as unknown as UnsignedReleaseStatus; + await expect(signReleaseStatus(unknown, privateKey)).rejects.toBeInstanceOf(ReleaseManifestError); + const forever = { ...RELEASE_STATUS_PUBLISHED_FIXTURE, expiresAt: "2027-10-13T04:31:00Z" }; + await expect(signReleaseStatus(forever, privateKey)).rejects.toBeInstanceOf(ReleaseManifestError); + }); +}); diff --git a/packages/contracts/src/release-status.ts b/packages/contracts/src/release-status.ts new file mode 100644 index 0000000..f3d5cb8 --- /dev/null +++ b/packages/contracts/src/release-status.ts @@ -0,0 +1,369 @@ +/** + * `skillx.release-status/v1` signed release status contract. + * + * The `skillx.release/v1` manifest is signed once and never changes. The + * mutable lifecycle state of that release (published, yanked, quarantined, + * ...) is a separate document, signed with the same canonical-bytes and + * Ed25519 rules (`release-signature.ts`). A status names exactly one release + * by `manifestDigest` (`releaseManifestDigest`), because the same tree + * (`releaseDigest`) may be released under several identities or versions. + * + * Freshness: every status expires. `expiresAt` is after `issuedAt` and at + * most `RELEASE_STATUS_MAX_LIFETIME_SECONDS` (7 days, a hard cap) later; the + * registry re-signs the current status every 24 hours with + * `expiresAt = issuedAt + 24h`, so a consumer that fetches normally always + * holds an unexpired one. A verifier rejects a status whose `expiresAt` is + * before the caller's `now` (`status.expired`) or whose `issuedAt` is more + * than `RELEASE_STATUS_MAX_CLOCK_SKEW_SECONDS` after it + * (`status.not_yet_valid`). A consumer may keep using an expired status only + * for content it already installed, never to authorize a new resolution or + * install. + * + * Each new status for a release carries a strictly higher `sequence`, tracked + * per `manifestDigest`, so a consumer that keeps the last status it accepted + * for that manifest can reject a replayed older one: + * + * - a lower `sequence` than the last accepted one is a rollback + * (`status.rollback`); + * - the same `sequence` with different signed bytes is a conflict + * (`status.conflict`); an identical re-fetch is accepted; + * - a higher `sequence` must have a later `issuedAt` + * (`status.issued_at_regression`) and a state reachable by + * `RELEASE_STATE_TRANSITIONS` (`status.invalid_transition`); + * - a `state` outside `RELEASE_STATES` fails closed (`status.unknown_state`), + * even when correctly signed, so an older consumer never guesses. + * + * Validators and verifiers never throw on any document input; invalid caller + * options (`manifest`, `now`, `previous`) throw `ReleaseManifestError`. + */ + +import { isFormat } from "@skillx/skill-tree/rules"; +import { canonicalJsonBytes } from "./canonical-json"; +import { RELEASE_STATES, isReleaseState } from "./package-manifest"; +import type { ReleaseState } from "./package-manifest"; +import { releaseManifestDigest, validateReleaseManifest } from "./release-manifest"; +import type { SkillxReleaseManifest } from "./release-manifest"; +import { + RELEASE_SIGNATURE_ALGORITHM, + ReleaseManifestError, + checkCanonicalValue, + checkFields, + checkSignatureMembers, + isReleaseTimestamp, + parseReceivedDocument, + signPayload, + timestampMs, + verifyPayload, +} from "./release-signature"; +import type { ReleasePublicKeys } from "./release-signature"; +import { isPlainObject, issue, validationFail, validationOk } from "./validation"; +import type { ValidationIssue, ValidationResult } from "./validation"; +import { RELEASE_DIGEST_PATTERN } from "./verification-evidence"; + +export const SKILLX_RELEASE_STATUS_SCHEMA_VERSION = "skillx.release-status/v1"; +/** Maximum `reason` length in UTF-8 bytes. */ +export const RELEASE_STATUS_REASON_MAX_BYTES = 512; +/** Maximum `expiresAt - issuedAt`: 7 days. */ +export const RELEASE_STATUS_MAX_LIFETIME_SECONDS = 7 * 24 * 60 * 60; +/** + * How far `issuedAt` may lie ahead of the consumer's clock. A status issued + * further in the future is `status.not_yet_valid`: it would otherwise stay + * valid past the lifetime bound and, once accepted as `previous`, make every + * correctly timed later status an `issuedAt` regression. + */ +export const RELEASE_STATUS_MAX_CLOCK_SKEW_SECONDS = 300; + +/** Status-specific failure codes, in addition to the signature codes. */ +export const RELEASE_STATUS_CODES = { + nonCanonical: "status.non_canonical", + unknownState: "status.unknown_state", + invalidLifetime: "status.invalid_lifetime", + digestMismatch: "status.digest_mismatch", + manifestMismatch: "status.manifest_mismatch", + issuedBeforeRelease: "status.issued_before_release", + notYetValid: "status.not_yet_valid", + expired: "status.expired", + rollback: "status.rollback", + conflict: "status.conflict", + issuedAtRegression: "status.issued_at_regression", + invalidTransition: "status.invalid_transition", +} as const; + +/** States a release's first status (`sequence` 1) may have. */ +export const RELEASE_INITIAL_STATES: readonly ReleaseState[] = ["draft", "published"]; + +/** + * Allowed state changes between consecutive statuses of one release. Every + * state may repeat (the daily re-sign); nothing returns to `draft`. + */ +export const RELEASE_STATE_TRANSITIONS: Readonly> = { + draft: ["draft", "published"], + published: ["published", "yanked", "quarantined"], + yanked: ["yanked", "published"], + quarantined: ["quarantined", "yanked"], +}; + +export interface UnsignedReleaseStatus { + schemaVersion: typeof SKILLX_RELEASE_STATUS_SCHEMA_VERSION; + /** The `releaseDigest` of the `skillx.release/v1` manifest this status describes. */ + releaseDigest: string; + /** `releaseManifestDigest` of that manifest: names exactly one release. */ + manifestDigest: string; + state: ReleaseState; + /** Optional human-readable explanation, e.g. why a release was yanked. Omit the member when there is none. */ + reason?: string; + /** Not before the manifest's `createdAt`. */ + issuedAt: string; + /** After `issuedAt`, at most `RELEASE_STATUS_MAX_LIFETIME_SECONDS` later. */ + expiresAt: string; + /** Strictly increasing per `manifestDigest`, starting at 1. */ + sequence: number; + keyId: string; + signatureAlgorithm: typeof RELEASE_SIGNATURE_ALGORITHM; +} + +export interface SkillxReleaseStatus extends UnsignedReleaseStatus { + signature: string; +} + +export interface ReleaseStatusVerifyOptions { + /** The verified `skillx.release/v1` manifest the status must describe. */ + manifest: SkillxReleaseManifest; + /** The consumer's current time, compared with `expiresAt`. */ + now: Date; + /** + * The status this consumer last accepted for the same manifest, if any. It + * must be a status this function accepted before; an invalid one, or one + * for another manifest, throws. + */ + previous?: SkillxReleaseStatus | null; +} + +const STATUS_FIELDS = [ + "schemaVersion", + "releaseDigest", + "manifestDigest", + "state", + "issuedAt", + "expiresAt", + "sequence", + "keyId", + "signatureAlgorithm", +] as const; +const OPTIONAL_STATUS_FIELDS = ["reason"] as const; +const TIMESTAMP_FORMAT = "must be an RFC 3339 UTC timestamp with whole seconds, e.g. 2026-10-13T04:30:00Z"; +/** + * Whether a `reason` code point is forbidden: Cc (C0, DEL, C1), Cf (every + * bidi control included) from the pinned Unicode 15.0 tables that path rules + * use, Zl (U+2028), Zp (U+2029) and Cs (surrogates). The pinned table keeps + * the result independent of the runtime's Unicode version. + */ +function forbiddenReasonCodePoint(cp: number): boolean { + return ( + cp < 0x20 || + (cp >= 0x7f && cp <= 0x9f) || + cp === 0x2028 || + cp === 0x2029 || + (cp >= 0xd800 && cp <= 0xdfff) || + isFormat(cp) + ); +} +/** Longest chain of distinct transitions; a larger sequence gap allows nothing more. */ +const MAX_TRANSITION_STEPS = 4; + +function validateReason(reason: unknown, issues: ValidationIssue[]): void { + if (typeof reason !== "string") { + issues.push(issue("reason", "type", "must be a string when present")); + return; + } + const bytes = new TextEncoder().encode(reason).length; + if (bytes === 0 || bytes > RELEASE_STATUS_REASON_MAX_BYTES) { + issues.push(issue("reason", "length", `must be 1 to ${RELEASE_STATUS_REASON_MAX_BYTES} UTF-8 bytes`)); + } else if (Array.from(reason).some((c) => forbiddenReasonCodePoint(c.codePointAt(0)!))) { + issues.push(issue("reason", "format", "must not contain control, format, bidi or line separator characters")); + } +} + +function validateLifetime(input: Record, issues: ValidationIssue[]): void { + const issuedOk = isReleaseTimestamp(input.issuedAt); + const expiresOk = isReleaseTimestamp(input.expiresAt); + // A missing member is already reported as `required`. + if (!issuedOk && "issuedAt" in input) issues.push(issue("issuedAt", "format", TIMESTAMP_FORMAT)); + if (!expiresOk && "expiresAt" in input) issues.push(issue("expiresAt", "format", TIMESTAMP_FORMAT)); + if (!issuedOk || !expiresOk) return; + const lifetime = (timestampMs(input.expiresAt as string) - timestampMs(input.issuedAt as string)) / 1000; + if (lifetime <= 0 || lifetime > RELEASE_STATUS_MAX_LIFETIME_SECONDS) { + issues.push( + issue( + "expiresAt", + RELEASE_STATUS_CODES.invalidLifetime, + `must be after issuedAt and at most ${RELEASE_STATUS_MAX_LIFETIME_SECONDS} seconds later`, + ), + ); + } +} + +function collectIssues(input: unknown, requireSignature: boolean): ValidationIssue[] { + if (!isPlainObject(input)) return [issue("", "type", "release status must be an object")]; + const issues: ValidationIssue[] = []; + if (!checkCanonicalValue(input, issues)) return issues; + const required = requireSignature ? [...STATUS_FIELDS, "signature"] : STATUS_FIELDS; + checkFields(input, required, "", SKILLX_RELEASE_STATUS_SCHEMA_VERSION, issues, OPTIONAL_STATUS_FIELDS); + + if (input.schemaVersion !== SKILLX_RELEASE_STATUS_SCHEMA_VERSION) { + issues.push(issue("schemaVersion", "unsupported_value", `must equal '${SKILLX_RELEASE_STATUS_SCHEMA_VERSION}'`)); + } + for (const field of ["releaseDigest", "manifestDigest"] as const) { + const value = input[field]; + if (field in input && (typeof value !== "string" || !RELEASE_DIGEST_PATTERN.test(value))) { + issues.push(issue(field, "pattern", "must match sha256:<64 lowercase hex>")); + } + } + const stateOk = isReleaseState(input.state); + if ("state" in input && !stateOk) { + issues.push(issue("state", RELEASE_STATUS_CODES.unknownState, `must be one of: ${RELEASE_STATES.join(", ")}`)); + } + if ("reason" in input) validateReason(input.reason, issues); + validateLifetime(input, issues); + const sequencePresent = "sequence" in input; + if (sequencePresent && (!Number.isSafeInteger(input.sequence) || (input.sequence as number) < 1)) { + issues.push(issue("sequence", "type", "must be a positive safe integer")); + } else if (input.sequence === 1 && stateOk && !RELEASE_INITIAL_STATES.includes(input.state as ReleaseState)) { + issues.push( + issue("state", RELEASE_STATUS_CODES.invalidTransition, `the first status must be one of: ${RELEASE_INITIAL_STATES.join(", ")}`), + ); + } + checkSignatureMembers(input, requireSignature, issues); + return issues; +} + +/** Validates a signed `skillx.release-status/v1` document (structure only, not the signature). */ +export function validateReleaseStatus(input: unknown): ValidationResult { + const issues = collectIssues(input, true); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** Validates a status before it is signed: every field except `signature`, which must be absent. */ +export function validateUnsignedReleaseStatus(input: unknown): ValidationResult { + const issues = collectIssues(input, false); + return issues.length === 0 ? validationOk() : validationFail(issues); +} + +/** The exact bytes a status signature covers: the canonical JSON of the status without `signature`. */ +export function releaseStatusSigningPayload(status: UnsignedReleaseStatus | SkillxReleaseStatus): Uint8Array { + const { signature: _signature, ...payload } = status as Partial; + return canonicalJsonBytes(payload); +} + +/** Signs a valid unsigned status. Throws `ReleaseManifestError` when the status or key is invalid. */ +export async function signReleaseStatus(status: UnsignedReleaseStatus, privateKey: CryptoKey): Promise { + const result = validateUnsignedReleaseStatus(status); + if (!result.valid) throw new ReleaseManifestError("invalid release status", result.issues); + return { ...status, signature: await signPayload(releaseStatusSigningPayload(status), privateKey) }; +} + +/** Whether `to` is reachable from `from` in at most `steps` transitions. */ +export function isReachableState(from: ReleaseState, to: ReleaseState, steps: number): boolean { + let frontier = new Set([from]); + for (let i = 0; i < Math.min(steps, MAX_TRANSITION_STEPS); i++) { + const next = new Set(); + for (const state of frontier) for (const target of RELEASE_STATE_TRANSITIONS[state]) next.add(target); + if (next.has(to)) return true; + frontier = next; + } + return false; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +function fail(path: string, code: string, message: string): ValidationResult { + return validationFail([issue(path, code, message)]); +} + +function checkPrevious( + status: SkillxReleaseStatus, + payload: Uint8Array, + previous: SkillxReleaseStatus, + manifestDigest: string, +): ValidationResult { + const previousResult = validateReleaseStatus(previous); + if (!previousResult.valid || previous.manifestDigest !== manifestDigest) { + throw new ReleaseManifestError("previous status is not an accepted status of this release manifest", previousResult.issues); + } + if (status.sequence < previous.sequence) { + return fail("sequence", RELEASE_STATUS_CODES.rollback, `is lower than the last accepted sequence ${previous.sequence}`); + } + if (status.sequence === previous.sequence) { + return sameBytes(payload, releaseStatusSigningPayload(previous)) + ? validationOk() + : fail("sequence", RELEASE_STATUS_CODES.conflict, `differs from the accepted status with the same sequence ${previous.sequence}`); + } + if (timestampMs(status.issuedAt) <= timestampMs(previous.issuedAt)) { + return fail("issuedAt", RELEASE_STATUS_CODES.issuedAtRegression, `must be after the last accepted issuedAt ${previous.issuedAt}`); + } + if (!isReachableState(previous.state, status.state, status.sequence - previous.sequence)) { + return fail( + "state", + RELEASE_STATUS_CODES.invalidTransition, + `cannot follow ${previous.state} (sequence ${previous.sequence}) at sequence ${status.sequence}`, + ); + } + return validationOk(); +} + +/** + * Verifies a received status document for `options.manifest`, in this order: + * canonical bytes (`status.non_canonical`), structure (including + * `status.unknown_state`, `status.invalid_lifetime` and the first-status + * rule), `status.digest_mismatch`, `status.manifest_mismatch`, + * `status.issued_before_release`, the signature, `status.not_yet_valid` and + * `status.expired` against `options.now`, then, with `options.previous`, `status.rollback`, + * `status.conflict`, `status.issued_at_regression` and + * `status.invalid_transition`. Never throws for `document`; invalid options + * throw `ReleaseManifestError`. + */ +export async function verifyReleaseStatus( + document: string | Uint8Array, + publicKeys: ReleasePublicKeys, + options: ReleaseStatusVerifyOptions, +): Promise { + const manifestResult = validateReleaseManifest(options.manifest); + if (!manifestResult.valid) throw new ReleaseManifestError("manifest option is not a valid release manifest", manifestResult.issues); + const now = options.now instanceof Date ? options.now.getTime() : Number.NaN; + if (!Number.isFinite(now)) throw new ReleaseManifestError("now option must be a valid Date"); + const manifestDigest = await releaseManifestDigest(options.manifest); + + const parsed = parseReceivedDocument(document, RELEASE_STATUS_CODES.nonCanonical); + if (!("value" in parsed)) return parsed; + const structure = validateReleaseStatus(parsed.value); + if (!structure.valid) return structure; + const status = parsed.value as SkillxReleaseStatus; + if (status.releaseDigest !== options.manifest.releaseDigest) { + return fail("releaseDigest", RELEASE_STATUS_CODES.digestMismatch, "does not describe the expected release tree"); + } + if (status.manifestDigest !== manifestDigest) { + return fail("manifestDigest", RELEASE_STATUS_CODES.manifestMismatch, "describes another release manifest"); + } + if (timestampMs(status.issuedAt) < timestampMs(options.manifest.createdAt)) { + return fail("issuedAt", RELEASE_STATUS_CODES.issuedBeforeRelease, `is before the manifest createdAt ${options.manifest.createdAt}`); + } + const payload = releaseStatusSigningPayload(status); + const failure = await verifyPayload(payload, status.signature, status.keyId, publicKeys); + if (failure !== null) return validationFail([failure]); + if (timestampMs(status.issuedAt) > now + RELEASE_STATUS_MAX_CLOCK_SKEW_SECONDS * 1000) { + return fail( + "issuedAt", + RELEASE_STATUS_CODES.notYetValid, + `is more than ${RELEASE_STATUS_MAX_CLOCK_SKEW_SECONDS} seconds after the consumer's clock`, + ); + } + if (now > timestampMs(status.expiresAt)) { + return fail("expiresAt", RELEASE_STATUS_CODES.expired, `expired at ${status.expiresAt}`); + } + + const previous = options.previous; + if (previous === undefined || previous === null) return validationOk(); + return checkPrevious(status, payload, previous, manifestDigest); +} diff --git a/packages/contracts/src/release-vectors.test.ts b/packages/contracts/src/release-vectors.test.ts new file mode 100644 index 0000000..57734b5 --- /dev/null +++ b/packages/contracts/src/release-vectors.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "vitest"; +import { canonicalJson } from "./canonical-json"; +import { + RELEASE_FIXTURE_CONTENTS, + RELEASE_FIXTURE_KEY_ID, + RELEASE_MANIFEST_FIXTURE, + RELEASE_MANIFEST_FIXTURES, + RELEASE_MANIFEST_MINIMAL_FIXTURE, +} from "./fixtures/release-fixtures"; +import vectorFile from "./fixtures/release-manifest-v1.json"; +import { + RELEASE_MANIFEST_CODES, + RELEASE_SIGNATURE_CODES, + compareUtf8, + releaseSigningPayload, + releaseTreeDigest, + signReleaseManifest, + validateReleaseManifest, + verifyReleaseManifest, +} from "./release-manifest"; +import type { ReleaseFile, SkillxReleaseManifest, UnsignedReleaseManifest } from "./release-manifest"; +import { signPayload } from "./release-signature"; +import { serializeTree } from "@skillx/skill-tree/rules"; +import { + OTHER_VECTOR_KEY_SEED_TEXT, + VECTOR_KEY_SEED_TEXT, + base64, + clone, + decoder, + describeVectorKey, + encoder, + fromBase64, + goJsonStyle, + hex, + sha256, + vectorKey, + withNonZeroPaddingBits, + writeVectorFile, + writeVectorsRequested, +} from "./test-support/vector-key"; +import type { VectorKey, VectorKeyDescription } from "./test-support/vector-key"; + +/** + * Golden cross-language vectors for `skillx.release/v1`, exported for + * AgentKit (`fixtures/release-manifest-v1.json`). The signing key is derived + * from a public seed text, so the vectors are reproducible and the key is + * worthless outside tests. A verifier receives `document` (the exact served + * text) and must reach the vector's result from those bytes alone. + * + * Regenerate after an intentional contract change with + * `SKILLX_WRITE_RELEASE_VECTORS=1 pnpm vitest run packages/contracts/src`. + */ + +interface ReleaseVectors { + description: string; + canonicalization: string; + verifierRules: string[]; + testKey: VectorKeyDescription; + valid: Array<{ + name: string; + document: string; + manifest: SkillxReleaseManifest; + signingPayload: string; + signingPayloadSha256: string; + treeSerialization: string; + fileContentsBase64: Record; + }>; + rejected: Array<{ name: string; document: string; expectedCode: string }>; + invalidManifest: Array<{ name: string; manifest: unknown; expectedCode: string }>; +} + +/** + * Paths whose UTF-8 byte order (U+FF61 before U+1F600) is the reverse of + * their UTF-16 code unit order, where the surrogate pair 0xD83D sorts first. + */ +const ORDERING_CONTENTS: Readonly> = { + "SKILL.md": "---\nname: path-order\ndescription: Path ordering vector.\n---\n", + [`references/${String.fromCodePoint(0xff61)}.md`]: "Halfwidth ideographic full stop.\n", + [`references/${String.fromCodePoint(0x1f600)}.md`]: "Astral-plane emoji.\n", +}; + +async function treeFiles(contents: Readonly>): Promise { + const files: ReleaseFile[] = []; + for (const [path, text] of Object.entries(contents)) { + const bytes = encoder.encode(text); + files.push({ path, mode: "100644", sha256: hex(await sha256(bytes)), size: bytes.length }); + } + return files.sort((a, b) => compareUtf8(a.path, b.path)); +} + +async function orderingManifest(): Promise { + const files = await treeFiles(ORDERING_CONTENTS); + return { + ...RELEASE_MANIFEST_MINIMAL_FIXTURE, + identity: "@solo.dev/path-order", + version: "0.2.0", + releaseDigest: await releaseTreeDigest(files), + files, + fileCount: files.length, + treeSize: files.reduce((sum, f) => sum + f.size, 0), + }; +} + +function contentsBase64(contents: Readonly>): Record { + return Object.fromEntries(Object.entries(contents).map(([path, text]) => [path, base64(encoder.encode(text))])); +} + +/** Signs any manifest payload, including one `signReleaseManifest` refuses. */ +async function signRaw(manifest: Record, key: VectorKey): Promise { + const signature = await signPayload(releaseSigningPayload(manifest as unknown as UnsignedReleaseManifest), key.privateKey); + return { ...(manifest as unknown as UnsignedReleaseManifest), signature }; +} + +async function buildVectors(): Promise { + const key = await vectorKey(VECTOR_KEY_SEED_TEXT); + const other = await vectorKey(OTHER_VECTOR_KEY_SEED_TEXT); + + const valid: ReleaseVectors["valid"] = []; + const signedFull = await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, key.privateKey); + for (const [name, base, contents] of [ + ["publisher-verified multi-file release", RELEASE_MANIFEST_FIXTURE, RELEASE_FIXTURE_CONTENTS.full], + ["unverified minimal prerelease with unknown provenance", RELEASE_MANIFEST_MINIMAL_FIXTURE, RELEASE_FIXTURE_CONTENTS.minimal], + ["paths ordered by UTF-8 bytes, not UTF-16 code units", await orderingManifest(), ORDERING_CONTENTS], + ] as const) { + const manifest = await signReleaseManifest(base, key.privateKey); + const payload = releaseSigningPayload(manifest); + valid.push({ + name, + document: canonicalJson(manifest), + manifest, + signingPayload: decoder.decode(payload), + signingPayloadSha256: hex(await sha256(payload)), + treeSerialization: serializeTree(manifest.files), + fileContentsBase64: contentsBase64(contents), + }); + } + + const full = canonicalJson(signedFull); + const mismatch = RELEASE_SIGNATURE_CODES.mismatch; + const tamper = (mutate: (m: SkillxReleaseManifest) => void): string => { + const copy = clone(signedFull); + mutate(copy); + return canonicalJson(copy); + }; + const flipped = tamper((m) => { + const bytes = fromBase64(m.signature); + bytes[0] = bytes[0]! ^ 0x01; + m.signature = base64(bytes); + }); + const goStyleSignature = base64( + new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key.privateKey, goJsonStyle(releaseSigningPayload(RELEASE_MANIFEST_FIXTURE)))), + ); + const wrongTree = await signRaw({ ...RELEASE_MANIFEST_FIXTURE, releaseDigest: RELEASE_MANIFEST_MINIMAL_FIXTURE.releaseDigest }, key); + const fileHashChanged = clone(RELEASE_MANIFEST_FIXTURE); + fileHashChanged.files[0]!.sha256 = `${fileHashChanged.files[0]!.sha256.slice(0, 63)}0`; + const signedFileHashChanged = await signRaw(fileHashChanged as unknown as Record, key); + const nonCanonical = RELEASE_MANIFEST_CODES.nonCanonical; + + return { + description: + "skillx.release/v1 signed manifest vectors. The test key is derived from a public seed text and MUST NOT be trusted outside tests.", + canonicalization: + "signature = padded base64 Ed25519 over UTF-8 canonical JSON (RFC 8785 subset: members sorted by UTF-16 code units, no whitespace, non-negative safe integers only, only quote/backslash/C0 controls escaped, everything else raw UTF-8) of the manifest without its signature member; see packages/contracts/src/canonical-json.ts", + verifierRules: [ + "parse document and re-encode it canonically; any byte difference (whitespace, member order, duplicate member, escapes, number form, BOM, trailing bytes, invalid UTF-8) is release.non_canonical", + "validate the structure; an unknown member, including one differing from a known member only by case, is unknown_field; a signature not matching ^[A-Za-z0-9+/]{85}[AQgw]==$ (non-zero unused base64 bits included) is pattern", + "require releaseDigest == 'sha256:' + hex SHA-256 of treeSerialization (agentkit.skill-tree/v1 of files), else release.tree_digest_mismatch", + "verify the signature with the trusted key for keyId over the canonical JSON without signature: signature.unknown_key, signature.invalid_key or signature.mismatch", + ], + testKey: describeVectorKey(RELEASE_FIXTURE_KEY_ID, VECTOR_KEY_SEED_TEXT, key), + valid, + rejected: [ + { name: "version changed after signing", document: tamper((m) => void (m.version = "1.2.1")), expectedCode: mismatch }, + { name: "flipped signature byte", document: flipped, expectedCode: mismatch }, + { + name: "signed by another key under the trusted key ID", + document: canonicalJson(await signReleaseManifest(RELEASE_MANIFEST_FIXTURE, other.privateKey)), + expectedCode: mismatch, + }, + { + name: "unknown key ID", + document: tamper((m) => void (m.keyId = "skillx-unknown-key")), + expectedCode: RELEASE_SIGNATURE_CODES.unknownKey, + }, + { + name: "signature over an encoding/json-escaped payload", + document: tamper((m) => void (m.signature = goStyleSignature)), + expectedCode: mismatch, + }, + { + name: "signature with non-zero unused base64 bits (same bytes for a lenient decoder)", + document: tamper((m) => void (m.signature = withNonZeroPaddingBits(m.signature))), + expectedCode: "pattern", + }, + { + name: "releaseDigest is not the tree digest of files, correctly signed", + document: canonicalJson(wrongTree), + expectedCode: RELEASE_MANIFEST_CODES.treeDigestMismatch, + }, + { + name: "file hash changed, correctly signed", + document: canonicalJson(signedFileHashChanged), + expectedCode: RELEASE_MANIFEST_CODES.treeDigestMismatch, + }, + { + name: "member differing from a known one only by case", + document: canonicalJson({ ...signedFull, Version: "1.2.0" }), + expectedCode: "unknown_field", + }, + { name: "pretty-printed", document: JSON.stringify(signedFull, null, 2), expectedCode: nonCanonical }, + { + name: "members in insertion order", + document: JSON.stringify(signedFull), + expectedCode: nonCanonical, + }, + { name: "duplicate member", document: full.replace("{", '{"version":"9.9.9",'), expectedCode: nonCanonical }, + { name: "exponent number form", document: full.replace('"artifactSize":10240', '"artifactSize":1.024e4'), expectedCode: nonCanonical }, + { name: "escaped solidus", document: full.replace("scripts/check.sh", "scripts\\/check.sh"), expectedCode: nonCanonical }, + { name: "byte order mark", document: `${String.fromCodePoint(0xfeff)}${full}`, expectedCode: nonCanonical }, + { name: "trailing newline", document: `${full}\n`, expectedCode: nonCanonical }, + ], + invalidManifest: RELEASE_MANIFEST_FIXTURES.filter((c) => !c.expectedValid).map((c) => ({ + name: c.name, + manifest: c.value, + expectedCode: c.expectedIssueCode ?? "", + })), + }; +} + +const committed = vectorFile as unknown as ReleaseVectors; + +function trustedKeys(): Map { + return new Map([[committed.testKey.keyId, fromBase64(committed.testKey.publicKeyBase64)]]); +} + +describe("skillx.release/v1 cross-language vectors", () => { + it.runIf(writeVectorsRequested)("writes the vector file", async () => { + await writeVectorFile("release-manifest-v1.json", await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("match the committed vector file byte for byte in content", async () => { + expect(committed).toEqual(await buildVectors()); + }); + + it.skipIf(writeVectorsRequested)("verify every valid vector from its document with the published test key", async () => { + expect(committed.valid.length).toBe(3); + for (const vector of committed.valid) { + expect(await verifyReleaseManifest(vector.document, trustedKeys()), vector.name).toEqual({ valid: true, issues: [] }); + expect(vector.document).toBe(canonicalJson(vector.manifest)); + expect(decoder.decode(releaseSigningPayload(vector.manifest))).toBe(vector.signingPayload); + expect(serializeTree(vector.manifest.files)).toBe(vector.treeSerialization); + expect(Object.keys(vector.fileContentsBase64).sort(compareUtf8)).toEqual(vector.manifest.files.map((f) => f.path)); + expect(vector.manifest).not.toHaveProperty("state"); + } + }); + + it.skipIf(writeVectorsRequested)("order the ordering vector's paths differently from UTF-16 code units", () => { + const paths = committed.valid[2]!.manifest.files.map((f) => f.path); + expect([...paths].sort()).not.toEqual(paths); + expect([...paths].sort(compareUtf8)).toEqual(paths); + }); + + it.skipIf(writeVectorsRequested)("hash every vector file to its manifest entry and the tree to releaseDigest", async () => { + for (const vector of committed.valid) { + for (const file of vector.manifest.files) { + const bytes = fromBase64(vector.fileContentsBase64[file.path]!); + expect(bytes.length, file.path).toBe(file.size); + expect(hex(await sha256(bytes)), file.path).toBe(file.sha256); + } + expect(`sha256:${hex(await sha256(encoder.encode(vector.treeSerialization)))}`).toBe(vector.manifest.releaseDigest); + } + }); + + it.skipIf(writeVectorsRequested)("reject every rejected document with exactly its code", async () => { + const codes = new Set(); + for (const vector of committed.rejected) { + const result = await verifyReleaseManifest(vector.document, trustedKeys()); + expect(result.issues.map((i) => i.code), vector.name).toEqual([vector.expectedCode]); + codes.add(vector.expectedCode); + } + expect([...codes].sort()).toEqual( + [ + ...Object.values(RELEASE_MANIFEST_CODES), + RELEASE_SIGNATURE_CODES.mismatch, + RELEASE_SIGNATURE_CODES.unknownKey, + "unknown_field", + "pattern", + ].sort(), + ); + }); + + it.skipIf(writeVectorsRequested)("reject every invalid-manifest vector with its code", () => { + expect(committed.invalidManifest.length).toBeGreaterThan(10); + for (const vector of committed.invalidManifest) { + const result = validateReleaseManifest(vector.manifest); + expect(result.valid, vector.name).toBe(false); + expect(result.issues.map((i) => i.code), vector.name).toContain(vector.expectedCode); + } + }); +}); diff --git a/packages/contracts/src/test-support/vector-key.ts b/packages/contracts/src/test-support/vector-key.ts new file mode 100644 index 0000000..709924c --- /dev/null +++ b/packages/contracts/src/test-support/vector-key.ts @@ -0,0 +1,88 @@ +/** + * Test-only helpers for the signed cross-language release vectors. The keys + * are derived from public seed texts, so they are reproducible and worthless + * outside tests. Not exported from the package entry point. + */ + +export const VECTOR_KEY_SEED_TEXT = "skillx.release/v1 test vector key; public, never trusted"; +export const OTHER_VECTOR_KEY_SEED_TEXT = "skillx.release/v1 other test vector key; public, never trusted"; + +/** DER prefix of a PKCS #8 Ed25519 private key; the 32-byte seed follows. */ +const PKCS8_ED25519_PREFIX = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]; + +export interface VectorKey { + privateKey: CryptoKey; + seed: Uint8Array; + publicKey: Uint8Array; +} + +export interface VectorKeyDescription { + keyId: string; + seedText: string; + seedHex: string; + publicKeyHex: string; + publicKeyBase64: string; +} + +export const encoder = new TextEncoder(); +export const decoder = new TextDecoder(); +export const hex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +export const base64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)); +export const fromBase64 = (text: string): Uint8Array => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); +const fromBase64Url = (text: string) => fromBase64(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")); + +export async function sha256(data: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest("SHA-256", data)); +} + +/** Derives the Ed25519 key whose seed is the SHA-256 of `seedText`. */ +export async function vectorKey(seedText: string): Promise { + const seed = await sha256(encoder.encode(seedText)); + const pkcs8 = new Uint8Array([...PKCS8_ED25519_PREFIX, ...seed]); + const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]); + const jwk = await crypto.subtle.exportKey("jwk", privateKey); + return { privateKey, seed, publicKey: fromBase64Url(jwk.x ?? "") }; +} + +export function describeVectorKey(keyId: string, seedText: string, key: VectorKey): VectorKeyDescription { + return { keyId, seedText, seedHex: hex(key.seed), publicKeyHex: hex(key.publicKey), publicKeyBase64: base64(key.publicKey) }; +} + +export function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** The payload a Go implementation would get from encoding/json.Marshal-style HTML and line-separator escaping. */ +export function goJsonStyle(payload: Uint8Array): Uint8Array { + const text = decoder + .decode(payload) + .replace(/&/g, "\\u0026") + .replace(//g, "\\u003e") + .replace(new RegExp(String.fromCodePoint(0x2028), "g"), "\\u2028"); + return encoder.encode(text); +} + +/** + * The same signature bytes with non-zero unused bits in the last base64 data + * character: lenient decoders (atob, Go base64.StdEncoding) decode it to the + * original bytes, the strict signature pattern rejects it. + */ +export function withNonZeroPaddingBits(signature: string): string { + const last = signature.charAt(85); + const next = { A: "B", Q: "R", g: "h", w: "x" }[last]; + if (next === undefined) throw new Error(`unexpected final signature character ${last}`); + return `${signature.slice(0, 85)}${next}==`; +} + +/** Writes `data` as pretty JSON next to this package's fixtures (only when regenerating vectors). */ +export async function writeVectorFile(fileName: string, data: unknown): Promise { + // A computed specifier keeps Node typings out of the Worker typecheck that also covers this file. + const fsModule = "node:fs"; + const fs = (await import(/* @vite-ignore */ fsModule)) as { writeFileSync(path: URL, data: string): void }; + fs.writeFileSync(new URL(`../fixtures/${fileName}`, import.meta.url), `${JSON.stringify(data, null, 2)}\n`); +} + +/** True when the vector files should be regenerated instead of compared. */ +export const writeVectorsRequested = + (globalThis as { process?: { env: Record } }).process?.env.SKILLX_WRITE_RELEASE_VECTORS === "1"; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index cecfceb..9578260 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -3,13 +3,14 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true, "verbatimModuleSyntax": true, "isolatedModules": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true }, "include": ["src/**/*"] } diff --git a/packages/skill-tree/README.md b/packages/skill-tree/README.md index 06276b3..285dfce 100644 --- a/packages/skill-tree/README.md +++ b/packages/skill-tree/README.md @@ -10,6 +10,12 @@ and its digest. Go parity is pinned by the vendored conformance vectors under - Node.js 21.2 or later (`DecompressionStream("deflate-raw")`). - Cloudflare Workers: enable the `nodejs_compat` compatibility flag. The package imports `node:crypto` (`createHash`) for SHA-256. +- `@skillx/skill-tree/rules` exports only the pure path, collision and + identity rules plus the canonical tree serialization (`serializeTree`, + `compareUtf8`, `sortFiles`), with no Node.js import, no hashing and no + archive readers. Worker and browser code that validates manifests, such as + `@skillx/contracts`, imports it and hashes `serializeTree(files)` with + WebCrypto to recompute the tree digest. ## Ingest overhead guard (tar) diff --git a/packages/skill-tree/package.json b/packages/skill-tree/package.json index 4fc376b..7d3fd40 100644 --- a/packages/skill-tree/package.json +++ b/packages/skill-tree/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "agentkit.skill-tree/v1 canonical tree digest, archive readers, identity and exposure rules shared with AgentKit.", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rules": "./src/rules.ts" }, "types": "./src/index.ts", "engines": { diff --git a/packages/skill-tree/src/rules.ts b/packages/skill-tree/src/rules.ts new file mode 100644 index 0000000..72fa641 --- /dev/null +++ b/packages/skill-tree/src/rules.ts @@ -0,0 +1,18 @@ +/** + * `@skillx/skill-tree/rules`: the pure validation rules of + * `agentkit.skill-tree/v1` and package identity, with no Node.js or archive + * dependency. Worker and browser code (such as `@skillx/contracts`) imports + * this entry point instead of the package root, which also pulls in hashing + * and the tar and zip readers. + */ +export { ERROR_CODES, SkillTreeError, isSkillTreeError } from "./errors"; +export type { SkillTreeErrorCode } from "./errors"; + +export { isNoisePath, validatePath } from "./path-rules"; +export { checkCollisions, foldKey } from "./collision"; +export { UNICODE_VERSION, isFormat } from "./unicode-15"; + +export { IDENTITY_PATTERN, RESERVED_PUBLISHER, checkPublisher, validateIdentity } from "./identity"; + +export { MODE_EXECUTABLE, MODE_REGULAR, TREE_SCHEMA, compareUtf8, serializeTree, sortFiles } from "./tree-format"; +export type { FileMode, TreeFile } from "./tree-format"; diff --git a/packages/skill-tree/src/serialize.ts b/packages/skill-tree/src/serialize.ts index 1230d5f..213f008 100644 --- a/packages/skill-tree/src/serialize.ts +++ b/packages/skill-tree/src/serialize.ts @@ -1,66 +1,22 @@ /** - * Canonical serialization and digest of `agentkit.skill-tree/v1` (contract - * 4.8): a header line, then one ` ` line per file, - * sorted by the raw UTF-8 bytes of the path. The tree digest is `sha256:` - * plus the hex SHA-256 of the whole serialization. + * Digest of `agentkit.skill-tree/v1` (contract 4.8): `sha256:` plus the hex + * SHA-256 of the canonical serialization rendered by `tree-format.ts`. */ import { createHash } from "node:crypto"; - -export const TREE_SCHEMA = "agentkit.skill-tree/v1"; - -export const MODE_REGULAR = "100644"; -export const MODE_EXECUTABLE = "100755"; -export type FileMode = typeof MODE_REGULAR | typeof MODE_EXECUTABLE; - -export const MODE_SOURCE_RECORDED = "recorded"; -export const MODE_SOURCE_ASSUMED = "assumed"; -export type ModeSource = typeof MODE_SOURCE_RECORDED | typeof MODE_SOURCE_ASSUMED; - -/** One regular file of a tree. `sha256` is the lowercase hex digest of its content. */ -export interface TreeFile { - path: string; - mode: FileMode; - size: number; - sha256: string; -} - -/** The tree manifest; `files` are sorted by raw UTF-8 path bytes. */ -export interface TreeManifest { - schema: typeof TREE_SCHEMA; - digest: string; - modeSource: ModeSource; - files: TreeFile[]; -} - -/** - * Compares two well-formed strings by their UTF-8 bytes, which is code point - * order (UTF-16 code unit order differs above U+FFFF). - */ -export function compareUtf8(a: string, b: string): number { - const ia = a[Symbol.iterator](); - const ib = b[Symbol.iterator](); - for (;;) { - const ca = ia.next(); - const cb = ib.next(); - if (ca.done || cb.done) return ca.done && cb.done ? 0 : ca.done ? -1 : 1; - const diff = ca.value.codePointAt(0)! - cb.value.codePointAt(0)!; - if (diff !== 0) return diff < 0 ? -1 : 1; - } -} - -export function sortFiles(files: readonly T[]): T[] { - return [...files].sort((a, b) => compareUtf8(a.path, b.path)); -} - -/** - * Renders the canonical serialization. Only a validated file set, whose - * paths are unique, has a defined serialization. - */ -export function serializeTree(files: readonly TreeFile[]): string { - let out = `${TREE_SCHEMA}\n`; - for (const f of sortFiles(files)) out += `${f.mode} ${f.sha256} ${f.size} ${f.path}\n`; - return out; -} +import { serializeTree } from "./tree-format"; +import type { TreeFile } from "./tree-format"; + +export { + MODE_EXECUTABLE, + MODE_REGULAR, + MODE_SOURCE_ASSUMED, + MODE_SOURCE_RECORDED, + TREE_SCHEMA, + compareUtf8, + serializeTree, + sortFiles, +} from "./tree-format"; +export type { FileMode, ModeSource, TreeFile, TreeManifest } from "./tree-format"; export function sha256Hex(data: string | Uint8Array): string { return createHash("sha256").update(data).digest("hex"); diff --git a/packages/skill-tree/src/tree-format.ts b/packages/skill-tree/src/tree-format.ts new file mode 100644 index 0000000..2369e82 --- /dev/null +++ b/packages/skill-tree/src/tree-format.ts @@ -0,0 +1,63 @@ +/** + * The pure canonical serialization of `agentkit.skill-tree/v1` (contract + * 4.8): a header line, then one ` ` line per file, + * sorted by the raw UTF-8 bytes of the path. No hashing and no Node.js + * imports, so Worker and browser code can render the exact bytes the tree + * digest covers and hash them with WebCrypto. + */ + +export const TREE_SCHEMA = "agentkit.skill-tree/v1"; + +export const MODE_REGULAR = "100644"; +export const MODE_EXECUTABLE = "100755"; +export type FileMode = typeof MODE_REGULAR | typeof MODE_EXECUTABLE; + +export const MODE_SOURCE_RECORDED = "recorded"; +export const MODE_SOURCE_ASSUMED = "assumed"; +export type ModeSource = typeof MODE_SOURCE_RECORDED | typeof MODE_SOURCE_ASSUMED; + +/** One regular file of a tree. `sha256` is the lowercase hex digest of its content. */ +export interface TreeFile { + path: string; + mode: FileMode; + size: number; + sha256: string; +} + +/** The tree manifest; `files` are sorted by raw UTF-8 path bytes. */ +export interface TreeManifest { + schema: typeof TREE_SCHEMA; + digest: string; + modeSource: ModeSource; + files: TreeFile[]; +} + +/** + * Compares two well-formed strings by their UTF-8 bytes, which is code point + * order (UTF-16 code unit order differs above U+FFFF). + */ +export function compareUtf8(a: string, b: string): number { + const ia = a[Symbol.iterator](); + const ib = b[Symbol.iterator](); + for (;;) { + const ca = ia.next(); + const cb = ib.next(); + if (ca.done || cb.done) return ca.done && cb.done ? 0 : ca.done ? -1 : 1; + const diff = ca.value.codePointAt(0)! - cb.value.codePointAt(0)!; + if (diff !== 0) return diff < 0 ? -1 : 1; + } +} + +export function sortFiles(files: readonly T[]): T[] { + return [...files].sort((a, b) => compareUtf8(a.path, b.path)); +} + +/** + * Renders the canonical serialization. Only a validated file set, whose + * paths are unique, has a defined serialization. + */ +export function serializeTree(files: readonly TreeFile[]): string { + let out = `${TREE_SCHEMA}\n`; + for (const f of sortFiles(files)) out += `${f.mode} ${f.sha256} ${f.size} ${f.path}\n`; + return out; +} diff --git a/packages/skill-tree/test/release-manifest-vectors.test.ts b/packages/skill-tree/test/release-manifest-vectors.test.ts new file mode 100644 index 0000000..ee0b88e --- /dev/null +++ b/packages/skill-tree/test/release-manifest-vectors.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import * as root from "../src/index"; +import * as rules from "../src/rules"; +import { LIMITS, treeDigest } from "../src/index"; +import type { TreeFile } from "../src/index"; +import { sha256Hex, sortFiles } from "../src/serialize"; +// The release contract lives in @skillx/contracts, which depends on this package, so the +// guard that both packages agree on the tree imports it by path instead of by name. +import { RELEASE_TREE_LIMITS } from "../../contracts/src/release-manifest"; +import vectorFile from "../../contracts/src/fixtures/release-manifest-v1.json"; + +interface ReleaseVector { + name: string; + manifest: { releaseDigest: string; files: TreeFile[]; fileCount: number; treeSize: number }; + treeSerialization: string; + fileContentsBase64: Record; +} + +const vectors = (vectorFile as unknown as { valid: ReleaseVector[] }).valid; + +describe("skillx.release/v1 vectors against agentkit.skill-tree/v1", () => { + it("has vectors to check", () => { + expect(vectors.length).toBeGreaterThan(0); + }); + + it.each(vectors.map((v) => [v.name, v] as const))("%s: releaseDigest is the tree digest of its files", (_name, vector) => { + const files: TreeFile[] = vector.manifest.files.map((f) => { + const bytes = Buffer.from(vector.fileContentsBase64[f.path] ?? "", "base64"); + return { path: f.path, mode: f.mode, size: bytes.length, sha256: sha256Hex(bytes) }; + }); + expect(sortFiles(files)).toEqual(vector.manifest.files); + expect(treeDigest(files)).toBe(vector.manifest.releaseDigest); + expect(rules.serializeTree(files)).toBe(vector.treeSerialization); + expect(vector.manifest.fileCount).toBe(files.length); + expect(vector.manifest.treeSize).toBe(files.reduce((sum, f) => sum + f.size, 0)); + }); + + it("keeps the release limits equal to the tree limits", () => { + expect(RELEASE_TREE_LIMITS).toEqual({ maxFiles: LIMITS.maxFiles, maxFileSize: LIMITS.maxFileSize, maxTreeSize: LIMITS.maxTreeSize }); + }); + + it("exposes the same rule implementations from the rules entry point", () => { + expect(rules.validatePath).toBe(root.validatePath); + expect(rules.checkPublisher).toBe(root.checkPublisher); + expect(rules.foldKey).toBe(root.foldKey); + expect(rules.SkillTreeError).toBe(root.SkillTreeError); + expect(rules.serializeTree).toBe(root.serializeTree); + expect(rules.compareUtf8).toBe(root.compareUtf8); + expect(rules.sortFiles).toBe(sortFiles); + expect(rules.TREE_SCHEMA).toBe(root.TREE_SCHEMA); + }); + + it("digests exactly the bytes the pure serializer renders", () => { + for (const vector of vectors) { + expect(`sha256:${sha256Hex(rules.serializeTree(vector.manifest.files))}`).toBe(vector.manifest.releaseDigest); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf18901..fc91732 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,10 @@ importers: version: 5.9.3 packages/contracts: + dependencies: + '@skillx/skill-tree': + specifier: workspace:* + version: link:../skill-tree devDependencies: typescript: specifier: ^5.9.2