diff --git a/.changeset/bright-otters-sleep.md b/.changeset/bright-otters-sleep.md new file mode 100644 index 0000000000..e0854fc583 --- /dev/null +++ b/.changeset/bright-otters-sleep.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-lexicons": minor +--- + +Adds experimental package profile policy and release provenance lexicon contracts. diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json index b30765ea44..775a9de9d8 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profile.json @@ -84,6 +84,10 @@ "type": "string", "format": "datetime", "description": "ISO 8601 datetime for the package's last update." + }, + "extensions": { + "type": "unknown", + "description": "Opaque extension container keyed by NSID. The base profile Lexicon does not validate entries; consumers dispatch known entries to their embedded extension schemas. EmDash delegated-release policy uses com.emdashcms.experimental.package.profileExtension here." } } } diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json new file mode 100644 index 0000000000..5e43e3df40 --- /dev/null +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/profileExtension.json @@ -0,0 +1,48 @@ +{ + "lexicon": 1, + "id": "com.emdashcms.experimental.package.profileExtension", + "description": "EmDash delegated-release policy embedded inside a package profile record under its extensions field. EXPERIMENTAL: shape may change before stabilisation.", + "defs": { + "main": { + "type": "object", + "description": "Typed embedded object carrying the package repository anchor and optional delegated-release policy. Lives under the profile record's extensions map keyed by this NSID. The $type field is required when embedded; clients use it to dispatch to this schema.", + "required": ["repository"], + "properties": { + "repository": { + "type": "string", + "format": "uri", + "description": "Canonical HTTPS source repository URL. Lexicon validates URI syntax and length; consumers must require HTTPS and canonicalization.", + "maxLength": 1024 + }, + "releasePolicy": { + "type": "ref", + "ref": "#releasePolicy" + } + } + }, + "releasePolicy": { + "type": "object", + "description": "Optional delegated-release policy. Absent fields normalize for consumers to requireProvenance false, confirmation escalation-only, and no approvers.", + "properties": { + "requireProvenance": { + "type": "boolean", + "description": "Whether releases require a verifiable provenance reference." + }, + "confirmation": { + "type": "string", + "description": "When a release requires human confirmation. The generated TypeScript type exposes escalation-only and always; Lexicon runtime validators do not enforce knownValues, so consumers must reject other values.", + "knownValues": ["escalation-only", "always"] + }, + "approvers": { + "type": "array", + "description": "Atproto DIDs authorized to approve releases. Lexicon validates DID syntax and the 32-item cap; consumers must reject duplicate DIDs.", + "maxLength": 32, + "items": { + "type": "string", + "format": "did" + } + } + } + } + } +} diff --git a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json index ff1c5c87c3..5b7f7377f3 100644 --- a/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json +++ b/packages/registry-lexicons/lexicons/com/emdashcms/experimental/package/releaseExtension.json @@ -12,6 +12,42 @@ "type": "ref", "ref": "#declaredAccess", "description": "Structured per-category access manifest. The sandbox enforces every operation declared here at runtime." + }, + "provenance": { + "type": "ref", + "ref": "#provenance", + "description": "Optional reference to a provenance document for this release." + } + } + }, + "provenance": { + "type": "object", + "description": "Provenance document reference. Unknown predicate types are retained as present-but-unverifiable; consumers understand https://slsa.dev/provenance/v1 in v1. Lexicon validates URI syntax and field lengths; consumers validate multibase-multihash syntax and source and builder canonicalization.", + "required": ["predicateType", "url", "checksum", "sourceRepository", "builderId"], + "properties": { + "predicateType": { + "type": "string", + "maxLength": 1024 + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "checksum": { + "type": "string", + "description": "Multibase-encoded multihash of the provenance document bytes. Consumers validate the multibase-multihash syntax and supported hash function.", + "maxLength": 256 + }, + "sourceRepository": { + "type": "string", + "format": "uri", + "maxLength": 1024 + }, + "builderId": { + "type": "string", + "format": "uri", + "maxLength": 1024 } } }, diff --git a/packages/registry-lexicons/src/generated/index.ts b/packages/registry-lexicons/src/generated/index.ts index 7898b48245..e9340a668e 100644 --- a/packages/registry-lexicons/src/generated/index.ts +++ b/packages/registry-lexicons/src/generated/index.ts @@ -5,6 +5,7 @@ export * as ComEmdashcmsExperimentalAggregatorListReleases from "./types/com/emd export * as ComEmdashcmsExperimentalAggregatorResolvePackage from "./types/com/emdashcms/experimental/aggregator/resolvePackage.js"; export * as ComEmdashcmsExperimentalAggregatorSearchPackages from "./types/com/emdashcms/experimental/aggregator/searchPackages.js"; export * as ComEmdashcmsExperimentalPackageProfile from "./types/com/emdashcms/experimental/package/profile.js"; +export * as ComEmdashcmsExperimentalPackageProfileExtension from "./types/com/emdashcms/experimental/package/profileExtension.js"; export * as ComEmdashcmsExperimentalPackageRelease from "./types/com/emdashcms/experimental/package/release.js"; export * as ComEmdashcmsExperimentalPackageReleaseExtension from "./types/com/emdashcms/experimental/package/releaseExtension.js"; export * as ComEmdashcmsExperimentalPublisherProfile from "./types/com/emdashcms/experimental/publisher/profile.js"; diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts index d7d17b1954..a6d85c5183 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profile.ts @@ -83,6 +83,10 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.stringGraphemes(0, 140), ]), ), + /** + * Opaque extension container keyed by NSID. The base profile Lexicon does not validate entries; consumers dispatch known entries to their embedded extension schemas. EmDash delegated-release policy uses com.emdashcms.experimental.package.profileExtension here. + */ + extensions: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.unknown()), /** * Canonical AT URI of this profile record. Derived from the record's location at publish time; not authored by the publisher. Aggregators MUST reject records whose id does not match the AT URI they were fetched from. Mirrors FAIR's id-must-match-the-resolved-identifier rule. */ diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts new file mode 100644 index 0000000000..f8533b3073 --- /dev/null +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/profileExtension.ts @@ -0,0 +1,61 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; + +const _mainSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.profileExtension", + ), + ), + get releasePolicy() { + return /*#__PURE__*/ v.optional(releasePolicySchema); + }, + /** + * Canonical HTTPS source repository URL. Lexicon validates URI syntax and length; consumers must require HTTPS and canonicalization. + * @maxLength 1024 + */ + repository: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), +}); +const _releasePolicySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.profileExtension#releasePolicy", + ), + ), + /** + * Atproto DIDs authorized to approve releases. Lexicon validates DID syntax and the 32-item cap; consumers must reject duplicate DIDs. + * @maxLength 32 + */ + approvers: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.didString()), + [/*#__PURE__*/ v.arrayLength(0, 32)], + ), + ), + /** + * When a release requires human confirmation. The generated TypeScript type exposes escalation-only and always; Lexicon runtime validators do not enforce knownValues, so consumers must reject other values. + */ + confirmation: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.string<"always" | "escalation-only" | (string & {})>(), + ), + /** + * Whether releases require a verifiable provenance reference. + */ + requireProvenance: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), +}); + +type main$schematype = typeof _mainSchema; +type releasePolicy$schematype = typeof _releasePolicySchema; + +export interface mainSchema extends main$schematype {} +export interface releasePolicySchema extends releasePolicy$schematype {} + +export const mainSchema = _mainSchema as mainSchema; +export const releasePolicySchema = _releasePolicySchema as releasePolicySchema; + +export interface Main extends v.InferInput {} +export interface ReleasePolicy extends v.InferInput< + typeof releasePolicySchema +> {} diff --git a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts index 5b3c1fd86b..90a5f3e924 100644 --- a/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts +++ b/packages/registry-lexicons/src/generated/types/com/emdashcms/experimental/package/releaseExtension.ts @@ -135,6 +135,12 @@ const _mainSchema = /*#__PURE__*/ v.object({ get declaredAccess() { return declaredAccessSchema; }, + /** + * Optional reference to a provenance document for this release. + */ + get provenance() { + return /*#__PURE__*/ v.optional(provenanceSchema); + }, }); const _mediaAccessSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( @@ -224,6 +230,45 @@ const _pageFragmentsConstraintsSchema = /*#__PURE__*/ v.object({ ), ), }); +const _provenanceSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "com.emdashcms.experimental.package.releaseExtension#provenance", + ), + ), + /** + * @maxLength 1024 + */ + builderId: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + /** + * Multibase-encoded multihash of the provenance document bytes. Consumers validate the multibase-multihash syntax and supported hash function. + * @maxLength 256 + */ + checksum: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 256), + ]), + /** + * @maxLength 1024 + */ + predicateType: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1024), + ]), + /** + * @maxLength 1024 + */ + sourceRepository: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.genericUriString(), + [/*#__PURE__*/ v.stringLength(0, 1024)], + ), + /** + * @maxLength 2048 + */ + url: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.genericUriString(), [ + /*#__PURE__*/ v.stringLength(0, 2048), + ]), +}); const _usersAccessSchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -264,6 +309,7 @@ type networkRequestConstraints$schematype = type pageAccess$schematype = typeof _pageAccessSchema; type pageFragmentsConstraints$schematype = typeof _pageFragmentsConstraintsSchema; +type provenance$schematype = typeof _provenanceSchema; type usersAccess$schematype = typeof _usersAccessSchema; type usersReadConstraints$schematype = typeof _usersReadConstraintsSchema; @@ -283,6 +329,7 @@ export interface networkAccessSchema extends networkAccess$schematype {} export interface networkRequestConstraintsSchema extends networkRequestConstraints$schematype {} export interface pageAccessSchema extends pageAccess$schematype {} export interface pageFragmentsConstraintsSchema extends pageFragmentsConstraints$schematype {} +export interface provenanceSchema extends provenance$schematype {} export interface usersAccessSchema extends usersAccess$schematype {} export interface usersReadConstraintsSchema extends usersReadConstraints$schematype {} @@ -312,6 +359,7 @@ export const networkRequestConstraintsSchema = export const pageAccessSchema = _pageAccessSchema as pageAccessSchema; export const pageFragmentsConstraintsSchema = _pageFragmentsConstraintsSchema as pageFragmentsConstraintsSchema; +export const provenanceSchema = _provenanceSchema as provenanceSchema; export const usersAccessSchema = _usersAccessSchema as usersAccessSchema; export const usersReadConstraintsSchema = _usersReadConstraintsSchema as usersReadConstraintsSchema; @@ -356,6 +404,7 @@ export interface PageAccess extends v.InferInput {} export interface PageFragmentsConstraints extends v.InferInput< typeof pageFragmentsConstraintsSchema > {} +export interface Provenance extends v.InferInput {} export interface UsersAccess extends v.InferInput {} export interface UsersReadConstraints extends v.InferInput< typeof usersReadConstraintsSchema diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index c38ba88d05..03a11b241c 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -29,6 +29,7 @@ export * as AggregatorResolvePackage from "./generated/types/com/emdashcms/exper export * as AggregatorSearchPackages from "./generated/types/com/emdashcms/experimental/aggregator/searchPackages.js"; export * as PackageProfile from "./generated/types/com/emdashcms/experimental/package/profile.js"; +export * as PackageProfileExtension from "./generated/types/com/emdashcms/experimental/package/profileExtension.js"; export * as PackageRelease from "./generated/types/com/emdashcms/experimental/package/release.js"; export * as PackageReleaseExtension from "./generated/types/com/emdashcms/experimental/package/releaseExtension.js"; @@ -42,6 +43,7 @@ export * as PublisherVerification from "./generated/types/com/emdashcms/experime */ export const NSID = { packageProfile: "com.emdashcms.experimental.package.profile", + packageProfileExtension: "com.emdashcms.experimental.package.profileExtension", packageRelease: "com.emdashcms.experimental.package.release", packageReleaseExtension: "com.emdashcms.experimental.package.releaseExtension", publisherProfile: "com.emdashcms.experimental.publisher.profile", @@ -58,7 +60,7 @@ export type NSIDValue = (typeof NSID)[keyof typeof NSID]; /** * NSIDs of record-shaped lexicons in this package (one row per NSID in the - * publisher's repo). Embedded objects (`releaseExtension`) and shared defs + * publisher's repo). Embedded objects (`profileExtension`, `releaseExtension`) and shared defs * (`aggregator.defs`) are excluded — they don't address their own collection. * * Useful for consumers building OAuth `repo:` scopes or enumerating writable @@ -96,7 +98,7 @@ import type * as PublisherVerificationNs from "./generated/types/com/emdashcms/e * to its PDS. Used by `PublishingClient.putRecord` (and any other typed-write * helper) to ensure callers pass a record matching the collection's lexicon. * - * Embedded objects (`releaseExtension`, which lives inside a release record's + * Embedded objects (`profileExtension`, `releaseExtension`, which live inside profile and release records' * `extensions` map) and query/procedure NSIDs (the `aggregator.*` ones) are * deliberately absent -- they aren't standalone repo collections. */ diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index d8f8e08ba5..d93b6ff167 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -1,7 +1,14 @@ import { is, safeParse } from "@atcute/lexicons/validations"; import { describe, expect, it } from "vitest"; -import { NSID, PackageProfile, PackageRelease, PublisherProfile } from "../src/index.js"; +import { + NSID, + PackageProfile, + PackageProfileExtension, + PackageRelease, + PackageReleaseExtension, + PublisherProfile, +} from "../src/index.js"; /** * Smoke tests over the generated types and validation schemas. The goal isn't @@ -90,6 +97,92 @@ describe("PackageProfile", () => { expect(is(PackageProfile.mainSchema, known)).toBe(true); expect(is(PackageProfile.mainSchema, custom)).toBe(true); }); + + it("intentionally treats extension entries as opaque until consumers dispatch them", () => { + const profile: PackageProfile.Main = { + $type: NSID.packageProfile, + id: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/gallery", + type: "emdash-plugin", + license: "MIT", + authors: [{ name: "Alice Example" }], + security: [{ email: "security@example.com" }], + extensions: { + [NSID.packageProfileExtension]: "not validated by the base profile schema", + }, + }; + + expect(safeParse(PackageProfile.mainSchema, profile)).toMatchObject({ ok: true }); + // Consumers validate an entry only after dispatching its NSID to this schema. + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + }), + ).toBe(true); + }); +}); + +describe("PackageProfileExtension", () => { + it("round-trips a release policy", () => { + const extension: PackageProfileExtension.Main = { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + requireProvenance: true, + confirmation: "always", + approvers: ["did:plc:abc123"], + }, + }; + + expect(safeParse(PackageProfileExtension.mainSchema, extension)).toMatchObject({ ok: true }); + }); + + it("accepts an absent policy", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + }), + ).toBe(true); + }); + + it("rejects invalid repository URIs, approver DIDs, and oversized approver lists", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "not-a-uri", + }), + ).toBe(false); + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { approvers: ["not-a-did"] }, + }), + ).toBe(false); + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + approvers: Array.from({ length: 33 }, (_, index) => `did:plc:${index}`), + }, + }), + ).toBe(false); + }); + + it("intentionally defers unknown confirmation values and duplicate approvers to consumers", () => { + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { + confirmation: "manual-review", + approvers: ["did:plc:abc123", "did:plc:abc123"], + }, + }), + ).toBe(true); + }); }); describe("PackageRelease", () => { @@ -128,6 +221,55 @@ describe("PackageRelease", () => { }); }); +describe("PackageReleaseExtension", () => { + it("intentionally leaves unknown provenance predicates for consumer verification", () => { + const extension: PackageReleaseExtension.Main = { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + predicateType: "https://example.com/provenance/v2", + url: "https://github.com/example/gallery/attestation.json", + checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + sourceRepository: "https://github.com/example/gallery", + builderId: + "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", + }, + }; + + expect(safeParse(PackageReleaseExtension.mainSchema, extension)).toMatchObject({ ok: true }); + }); + + it("rejects incomplete provenance", () => { + expect( + is(PackageReleaseExtension.mainSchema, { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + provenance: { + predicateType: "https://slsa.dev/provenance/v1", + url: "https://github.com/example/gallery/attestation.json", + }, + }), + ).toBe(false); + }); + + it("does not enforce profile policy across records", () => { + // requireProvenance is enforced by later consumers, not a cross-record Lexicon rule. + expect( + is(PackageProfileExtension.mainSchema, { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/gallery", + releasePolicy: { requireProvenance: true }, + }), + ).toBe(true); + expect( + is(PackageReleaseExtension.mainSchema, { + $type: NSID.packageReleaseExtension, + declaredAccess: {}, + }), + ).toBe(true); + }); +}); + describe("PublisherProfile", () => { it("validates a publisher profile with only required fields", () => { // Only `displayName` is required by the lexicon. Verification records bind @@ -157,6 +299,7 @@ describe("NSID map", () => { // also sanity-checked in the schema modules' `$type` literals above. const expected = [ "com.emdashcms.experimental.package.profile", + "com.emdashcms.experimental.package.profileExtension", "com.emdashcms.experimental.package.release", "com.emdashcms.experimental.package.releaseExtension", "com.emdashcms.experimental.publisher.profile",