From 136edea20649b8fcab73d8fa14e31775fa38cc04 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 12 Jul 2026 20:51:00 +0100 Subject: [PATCH] feat(registry): add create-only delegated publishing --- .changeset/create-only-delegated-releases.md | 6 + packages/registry-client/src/index.ts | 7 +- .../registry-client/src/publishing/index.ts | 86 +++++++- .../registry-client/tests/publishing.test.ts | 191 +++++++++++++++++- packages/registry-lexicons/README.md | 2 + packages/registry-lexicons/src/index.ts | 13 ++ .../registry-lexicons/tests/types.test.ts | 14 ++ 7 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 .changeset/create-only-delegated-releases.md diff --git a/.changeset/create-only-delegated-releases.md b/.changeset/create-only-delegated-releases.md new file mode 100644 index 0000000000..8cdf3cf241 --- /dev/null +++ b/.changeset/create-only-delegated-releases.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/registry-client": minor +"@emdash-cms/registry-lexicons": minor +--- + +Adds the exact create-only delegated release scope and a narrow API for publishing immutable package releases. diff --git a/packages/registry-client/src/index.ts b/packages/registry-client/src/index.ts index 0f31b3aa2d..14f4703785 100644 --- a/packages/registry-client/src/index.ts +++ b/packages/registry-client/src/index.ts @@ -41,7 +41,12 @@ export { defaultCredentialStore, } from "./credentials/index.js"; -export { type PublishingClientFromHandlerOptions, PublishingClient } from "./publishing/index.js"; +export { + type CreateDelegatedReleaseOptions, + type PublishingClientFromHandlerOptions, + PublishingClient, + createDelegatedRelease, +} from "./publishing/index.js"; export { type DiscoveryClientOptions, DiscoveryClient } from "./discovery/index.js"; diff --git a/packages/registry-client/src/publishing/index.ts b/packages/registry-client/src/publishing/index.ts index f35915b576..2e6b1859fe 100644 --- a/packages/registry-client/src/publishing/index.ts +++ b/packages/registry-client/src/publishing/index.ts @@ -46,9 +46,19 @@ // pattern, hence the disable. // eslint-disable-next-line @typescript-eslint/no-empty-named-blocks, eslint-plugin-import/no-empty-named-blocks, eslint-plugin-unicorn/require-module-specifiers, import/no-empty-named-blocks, unicorn/require-module-specifiers import type {} from "@atcute/atproto"; +import { encode } from "@atcute/cbor"; +import * as CID from "@atcute/cid"; import { Client, type FetchHandler, type FetchHandlerObject, ok } from "@atcute/client"; import type { Nsid } from "@atcute/lexicons"; -import type { RegistryRecordCollection, RegistryRecords } from "@emdash-cms/registry-lexicons"; +import { isDid } from "@atcute/lexicons/syntax"; +import { safeParse } from "@atcute/lexicons/validations"; +import { + getDelegatedReleasePermission, + PackageRelease, + type RegistryRecordCollection, + type RegistryRecords, +} from "@emdash-cms/registry-lexicons"; +import valid from "semver/functions/valid.js"; import type { Did } from "../credentials/types.js"; @@ -112,6 +122,80 @@ export interface ApplyWritesResult { >; } +export interface CreateDelegatedReleaseOptions { + /** Authenticated atproto handler carrying the exact create-only grant. */ + handler: FetchHandler | FetchHandlerObject; + /** Publisher DID whose repository receives the release. */ + did: Did; + /** Locally verified package release record. */ + release: PackageRelease.Main; +} + +const PACKAGE_SLUG_RE = /^[a-z][a-z0-9_-]{0,63}$/; + +/** + * Publish one immutable package release through the delegated create-only path. + * + * The API intentionally exposes no collection, rkey, update, delete, overwrite, + * profile-write, or server-validation options. + */ +export async function createDelegatedRelease( + options: CreateDelegatedReleaseOptions, +): Promise<{ uri: string; cid: string }> { + const { did, handler, release } = options; + let snapshot: PackageRelease.Main; + try { + snapshot = structuredClone(release); + } catch { + throw new TypeError("Invalid delegated release record"); + } + const parsed = safeParse(PackageRelease.mainSchema, snapshot); + if (!parsed.ok) throw new TypeError("Invalid delegated release record"); + + const { package: packageSlug, version } = parsed.value; + if (!PACKAGE_SLUG_RE.test(packageSlug) || version.includes("+") || valid(version) !== version) { + throw new TypeError("Invalid delegated release identity"); + } + if (!isDid(did)) throw new TypeError("Invalid delegated release publisher DID"); + + const { collection } = getDelegatedReleasePermission(); + const rkey = `${packageSlug}:${version}`; + const expectedUri = `at://${did}/${collection}/${rkey}`; + const expectedCid = await CID.create(CID.CODEC_DCBOR, encode(parsed.value)); + const client = new Client({ handler }); + const data = await ok( + client.post("com.atproto.repo.createRecord", { + input: { + repo: did, + collection, + rkey, + record: parsed.value, + // Experimental lexicons are not installed on every supported PDS. + validate: false, + }, + }), + ); + + if (data.uri !== expectedUri) { + throw new Error("The PDS returned an invalid delegated release identity"); + } + + let claimedCid: CID.Cid; + try { + claimedCid = CID.fromString(data.cid); + if (claimedCid.codec !== CID.CODEC_DCBOR || CID.toString(claimedCid) !== data.cid) { + throw new Error(); + } + } catch { + throw new Error("The PDS returned an invalid delegated release CID"); + } + if (!CID.equals(claimedCid, expectedCid)) { + throw new Error("The PDS returned a mismatched delegated release CID"); + } + + return { uri: data.uri, cid: data.cid }; +} + /** * High-level operations against a publisher's atproto repo, scoped to the * registry's NSIDs. diff --git a/packages/registry-client/tests/publishing.test.ts b/packages/registry-client/tests/publishing.test.ts index 318a8aa52a..dcae7ad1df 100644 --- a/packages/registry-client/tests/publishing.test.ts +++ b/packages/registry-client/tests/publishing.test.ts @@ -1,8 +1,10 @@ +import { encode } from "@atcute/cbor"; +import * as CID from "@atcute/cid"; import { ClientResponseError } from "@atcute/client"; import { describe, expect, it, vi } from "vitest"; import type { Did } from "../src/credentials/index.js"; -import { PublishingClient } from "../src/publishing/index.js"; +import { PublishingClient, createDelegatedRelease } from "../src/publishing/index.js"; function buildHandler(responses: Record): { handler: (pathname: string, init: RequestInit) => Promise; @@ -219,3 +221,190 @@ describe("PublishingClient", () => { expect(body.writes).toHaveLength(2); }); }); + +describe("createDelegatedRelease", () => { + const did = "did:plc:abc123" as Did; + const release = { + $type: "com.emdashcms.experimental.package.release" as const, + package: "gallery", + version: "1.0.0", + artifacts: { + package: { + url: "https://example.com/gallery-1.0.0.tgz", + checksum: "bciqkkpvkbtfcwq6kjkbq3kgjxe5j6ihzkxlfxkzqhwzaaaa3wkbq3a", + }, + }, + }; + + it("creates exactly one deterministic release record", async () => { + const uri = "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0"; + const cid = await recordCid(release); + const { handler, calls } = buildHandler({ + "/xrpc/com.atproto.repo.createRecord": { + status: 200, + body: { uri, cid }, + }, + }); + + await expect(createDelegatedRelease({ handler, did, release })).resolves.toEqual({ + uri, + cid, + }); + expect(calls).toHaveLength(1); + expect(calls[0]!.pathname).toBe("/xrpc/com.atproto.repo.createRecord"); + expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ + repo: did, + collection: "com.emdashcms.experimental.package.release", + rkey: "gallery:1.0.0", + record: release, + validate: false, + }); + }); + + it.each([ + ["invalid lexicon", { ...release, artifacts: {} }], + ["invalid package slug", { ...release, package: "Gallery" }], + ["non-canonical version", { ...release, version: "1.0.0+build" }], + ])("rejects %s before making a request", async (_name, candidate) => { + const { handler, calls } = buildHandler({}); + + await expect( + createDelegatedRelease({ handler, did, release: candidate as typeof release }), + ).rejects.toBeInstanceOf(TypeError); + expect(calls).toHaveLength(0); + }); + + it("rejects an invalid publisher DID before making a request", async () => { + const { handler, calls } = buildHandler({}); + + await expect( + createDelegatedRelease({ handler, did: "not-a-did" as Did, release }), + ).rejects.toThrow("Invalid delegated release publisher DID"); + expect(calls).toHaveLength(0); + }); + + it("captures accessor-backed options exactly once", async () => { + let didReads = 0; + const calls: Array<{ repo: string }> = []; + const handler = async (_pathname: string, init: RequestInit): Promise => { + const input = JSON.parse(init.body as string); + calls.push({ repo: input.repo }); + return new Response( + JSON.stringify({ + uri: `at://${input.repo}/${input.collection}/${input.rkey}`, + cid: await recordCid(input.record), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + const options = { + handler, + get did(): Did { + didReads += 1; + return (didReads === 1 ? did : "not-a-did") as Did; + }, + release, + }; + + await expect(createDelegatedRelease(options)).resolves.toMatchObject({ + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0", + }); + expect(didReads).toBe(1); + expect(calls).toEqual([{ repo: did }]); + }); + + it("binds validation, serialization, and CID verification to one snapshot", async () => { + const mutableRelease = structuredClone(release); + const handler = async (_pathname: string, init: RequestInit): Promise => { + const input = JSON.parse(init.body as string); + mutableRelease.version = "2.0.0"; + return new Response( + JSON.stringify({ + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0", + cid: await recordCid(input.record), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + await expect( + createDelegatedRelease({ handler, did, release: mutableRelease }), + ).resolves.toEqual({ + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0", + cid: await recordCid(release), + }); + }); + + it("does not fall back when create conflicts", async () => { + const { handler, calls } = buildHandler({ + "/xrpc/com.atproto.repo.createRecord": { + status: 400, + body: { error: "RecordAlreadyExists" }, + }, + }); + + await expect(createDelegatedRelease({ handler, did, release })).rejects.toBeInstanceOf( + ClientResponseError, + ); + expect(calls).toHaveLength(1); + }); + + it("rejects a response for a different record identity", async () => { + const cid = await recordCid(release); + const { handler } = buildHandler({ + "/xrpc/com.atproto.repo.createRecord": { + status: 200, + body: { + uri: "at://did:plc:other/com.emdashcms.experimental.package.release/gallery:1.0.0", + cid, + }, + }, + }); + + await expect(createDelegatedRelease({ handler, did, release })).rejects.toThrow( + "invalid delegated release identity", + ); + }); + + it("rejects a fragment-bearing record URI", async () => { + const cid = await recordCid(release); + const { handler } = buildHandler({ + "/xrpc/com.atproto.repo.createRecord": { + status: 200, + body: { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0#/other", + cid, + }, + }, + }); + + await expect(createDelegatedRelease({ handler, did, release })).rejects.toThrow( + "invalid delegated release identity", + ); + }); + + it.each([ + ["malformed", "not-a-cid", "invalid delegated release CID"], + [ + "mismatched", + "bafyreihyrpefhacm6kkp4ql6j6udakdit7g3dmkzfriqfykhjw6cad5lrm", + "mismatched delegated release CID", + ], + ])("rejects a %s response CID", async (_name, cid, message) => { + const { handler } = buildHandler({ + "/xrpc/com.atproto.repo.createRecord": { + status: 200, + body: { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/gallery:1.0.0", + cid, + }, + }, + }); + + await expect(createDelegatedRelease({ handler, did, release })).rejects.toThrow(message); + }); +}); + +async function recordCid(value: unknown): Promise { + return CID.toString(await CID.create(CID.CODEC_DCBOR, encode(value))); +} diff --git a/packages/registry-lexicons/README.md b/packages/registry-lexicons/README.md index ccfd6c7260..295d3222d8 100644 --- a/packages/registry-lexicons/README.md +++ b/packages/registry-lexicons/README.md @@ -61,4 +61,6 @@ Everything under `com.emdashcms.experimental.*` is unstable by design. The contr - Existing fields may have their constraints tightened or loosened in any release. - NSIDs may be renamed at the next stable cutover (see RFC 0001's migration plan). +Delegated release grants are collection-specific. When the release NSID moves out of the experimental namespace, every publisher must authorize a new grant for the stable collection. `getDelegatedReleasePermission()` returns the active collection and exact create-only OAuth scope so clients do not hard-code either value. + Once the registry is non-experimental, this package will publish a 1.0 with the post-experimental NSIDs and a stability commitment. diff --git a/packages/registry-lexicons/src/index.ts b/packages/registry-lexicons/src/index.ts index 03a11b241c..7151086855 100644 --- a/packages/registry-lexicons/src/index.ts +++ b/packages/registry-lexicons/src/index.ts @@ -58,6 +58,19 @@ export const NSID = { export type NSIDValue = (typeof NSID)[keyof typeof NSID]; +const DELEGATED_RELEASE_PERMISSION = Object.freeze({ + collection: NSID.packageRelease, + scope: `atproto repo:${NSID.packageRelease}?action=create`, +} as const); + +/** + * Return the exact collection and OAuth scope used by delegated publishing. + * A collection change requires every publisher to authorize a new grant. + */ +export function getDelegatedReleasePermission(): typeof DELEGATED_RELEASE_PERMISSION { + return DELEGATED_RELEASE_PERMISSION; +} + /** * NSIDs of record-shaped lexicons in this package (one row per NSID in the * publisher's repo). Embedded objects (`profileExtension`, `releaseExtension`) and shared defs diff --git a/packages/registry-lexicons/tests/types.test.ts b/packages/registry-lexicons/tests/types.test.ts index d93b6ff167..0b76f8ae1e 100644 --- a/packages/registry-lexicons/tests/types.test.ts +++ b/packages/registry-lexicons/tests/types.test.ts @@ -2,6 +2,7 @@ import { is, safeParse } from "@atcute/lexicons/validations"; import { describe, expect, it } from "vitest"; import { + getDelegatedReleasePermission, NSID, PackageProfile, PackageProfileExtension, @@ -10,6 +11,19 @@ import { PublisherProfile, } from "../src/index.js"; +describe("delegated release permission", () => { + it("exposes only the active release collection's create scope", () => { + const permission = getDelegatedReleasePermission(); + + expect(permission).toEqual({ + collection: NSID.packageRelease, + scope: "atproto repo:com.emdashcms.experimental.package.release?action=create", + }); + expect(Object.isFrozen(permission)).toBe(true); + expect(permission.scope).not.toContain("transition:generic"); + }); +}); + /** * Smoke tests over the generated types and validation schemas. The goal isn't * exhaustive coverage of the lexicons (the JSON files are the spec; codegen is