Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/create-only-delegated-releases.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/registry-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
86 changes: 85 additions & 1 deletion packages/registry-client/src/publishing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand Down
191 changes: 190 additions & 1 deletion packages/registry-client/tests/publishing.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { status: number; body: unknown }>): {
handler: (pathname: string, init: RequestInit) => Promise<Response>;
Expand Down Expand Up @@ -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<Response> => {
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<Response> => {
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<string> {
return CID.toString(await CID.create(CID.CODEC_DCBOR, encode(value)));
}
2 changes: 2 additions & 0 deletions packages/registry-lexicons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions packages/registry-lexicons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/registry-lexicons/tests/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { is, safeParse } from "@atcute/lexicons/validations";
import { describe, expect, it } from "vitest";

import {
getDelegatedReleasePermission,
NSID,
PackageProfile,
PackageProfileExtension,
Expand All @@ -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
Expand Down
Loading