diff --git a/.changeset/profile-policy-editor.md b/.changeset/profile-policy-editor.md new file mode 100644 index 0000000000..40ec086ce9 --- /dev/null +++ b/.changeset/profile-policy-editor.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-cli": minor +--- + +Adds `emdash-plugin policy set` for editing an existing package's signed release policy. diff --git a/packages/plugin-cli/src/commands/policy.ts b/packages/plugin-cli/src/commands/policy.ts new file mode 100644 index 0000000000..4b1e8543c9 --- /dev/null +++ b/packages/plugin-cli/src/commands/policy.ts @@ -0,0 +1,213 @@ +import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client"; +import { defineCommand } from "citty"; +import consola from "consola"; + +import { redirectConsolaToStderr } from "../cli-output.js"; +import { loadManifest, MANIFEST_FILENAME, ManifestError } from "../manifest/load.js"; +import { checkPublisher, PublisherCheckError } from "../manifest/publisher.js"; +import { resumeSession } from "../oauth.js"; +import { ProfilePolicyError, setProfilePolicy } from "../policy/api.js"; + +export const policySetArgs = { + manifest: { + type: "string", + description: `Manifest path or directory. Defaults to ./${MANIFEST_FILENAME}.`, + }, + repository: { + type: "string", + description: "HTTPS repository anchor, required only when creating the profile extension.", + }, + "require-provenance": { + type: "boolean", + description: "Require verifiable release provenance.", + }, + confirmation: { + type: "string", + description: "Release confirmation: escalation-only or always.", + }, + approver: { + type: "string", + description: "Approver DID. Repeat to replace the complete approver list.", + }, + "clear-approvers": { + type: "boolean", + description: "Replace the complete approver list with an empty list.", + }, + yes: { + type: "boolean", + default: false, + description: "Apply the policy change. The default is a dry-run.", + }, + json: { type: "boolean", description: "Emit stable single-line JSON to stdout." }, +} as const; + +export const policySetCommand = defineCommand({ + meta: { name: "set", description: "Edit the release policy on an existing package profile" }, + args: policySetArgs, + async run({ args, rawArgs }) { + const json = args.json === true; + const restoreReporters = json ? redirectConsolaToStderr() : null; + let exitCode = 0; + try { + const commandInput = resolvePolicyCommandInput(args, rawArgs); + const { manifest, path } = await loadPolicyManifest(commandInput.manifestPath); + const credentials = new FileCredentialStore(); + const session = await credentials.current(); + if (!session) + throw new CliError( + "Not logged in. Run: emdash-plugin login ", + "NOT_LOGGED_IN", + ); + try { + const publisherCheck = await checkPublisher({ + manifestPublisher: manifest.publisher, + sessionDid: session.did, + }); + if (publisherCheck.kind === "mismatch") { + throw new CliError( + `Manifest publisher ${publisherCheck.pinnedDid} does not match the active session ${session.did}.`, + "MANIFEST_PUBLISHER_MISMATCH", + ); + } + } catch (error) { + if (error instanceof PublisherCheckError) { + throw new CliError(error.message, error.code); + } + throw error; + } + consola.info(`Loaded manifest: ${path}`); + const oauthSession = await resumeSession(session.did); + const publisher = PublishingClient.fromHandler({ + handler: oauthSession, + did: session.did, + pds: session.pds, + }); + const result = await setProfilePolicy({ + publisher, + slug: manifest.slug, + apply: commandInput.apply, + input: commandInput.input, + }); + if (json) { + process.stdout.write( + `${JSON.stringify(formatPolicyJsonResult(result, commandInput.apply))}\n`, + ); + return; + } + if (result.diffs.length === 0) + consola.success("Package release policy is already up to date."); + else if (result.written) consola.success(`Updated release policy: ${result.profileUri}`); + else consola.info("Dry-run complete. Re-run with --yes to write this policy change."); + } catch (error) { + const code = + error instanceof ProfilePolicyError || error instanceof CliError + ? error.code + : "INTERNAL_ERROR"; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof ProfilePolicyError || error instanceof CliError ? error.detail : undefined; + consola.error(message); + if (json) { + process.stdout.write(`${JSON.stringify(formatPolicyJsonError(code, message, detail))}\n`); + } + exitCode = error instanceof CliError ? error.exitCode : 1; + } finally { + restoreReporters?.(); + } + if (exitCode !== 0) process.exit(exitCode); + }, +}); + +export const policyCommand = defineCommand({ + meta: { name: "policy", description: "Manage a package profile's release policy" }, + subCommands: { set: policySetCommand }, +}); + +export function resolvePolicyCommandInput(args: Record, rawArgs: string[]) { + const provenanceFlags = rawArgs.filter( + (arg) => arg === "--require-provenance" || arg === "--no-require-provenance", + ); + if (provenanceFlags.length > 1 || Array.isArray(args["require-provenance"])) { + throw new CliError( + "Specify --require-provenance or --no-require-provenance at most once.", + "INVALID_POLICY_FLAGS", + ); + } + const clearApproverFlags = rawArgs.filter((arg) => arg === "--clear-approvers"); + if (clearApproverFlags.length > 1 || Array.isArray(args["clear-approvers"])) { + throw new CliError("Specify --clear-approvers at most once.", "INVALID_POLICY_FLAGS"); + } + const approvers = toStringArray(args.approver); + if (args["clear-approvers"] === true && approvers !== undefined) { + throw new CliError( + "--clear-approvers cannot be combined with --approver.", + "INVALID_POLICY_FLAGS", + ); + } + const provenance = args["require-provenance"]; + if (provenance !== undefined && typeof provenance !== "boolean") { + throw new CliError("Invalid --require-provenance value.", "INVALID_POLICY_FLAGS"); + } + return { + manifestPath: typeof args.manifest === "string" ? args.manifest : `./${MANIFEST_FILENAME}`, + apply: args.yes === true, + input: { + repository: typeof args.repository === "string" ? args.repository : undefined, + requireProvenance: provenance, + confirmation: typeof args.confirmation === "string" ? args.confirmation : undefined, + approvers: args["clear-approvers"] === true ? [] : approvers, + }, + }; +} + +export function formatPolicyJsonResult( + result: Awaited>, + applied: boolean, +) { + return { + profile: result.profileUri, + written: result.written, + applied, + diffs: result.diffs.map((diff) => ({ + field: diff.field, + before: diff.before ?? null, + after: diff.after ?? null, + })), + ...(result.cid ? { cid: result.cid } : {}), + }; +} + +export function formatPolicyJsonError( + code: string, + message: string, + detail?: Record, +) { + return { error: { code, message, ...(detail === undefined ? {} : { detail }) } }; +} + +async function loadPolicyManifest(path: string) { + try { + return await loadManifest(path); + } catch (error) { + if (error instanceof ManifestError) throw new CliError(error.message, error.code); + throw error; + } +} + +function toStringArray(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + return typeof value === "string" ? [value] : undefined; +} + +class CliError extends Error { + override readonly name = "CliError"; + constructor( + message: string, + readonly code: string, + readonly exitCode = 1, + readonly detail?: Record, + ) { + super(message); + } +} diff --git a/packages/plugin-cli/src/index.ts b/packages/plugin-cli/src/index.ts index 4e337067d2..9501cfab6a 100644 --- a/packages/plugin-cli/src/index.ts +++ b/packages/plugin-cli/src/index.ts @@ -31,6 +31,7 @@ import { infoCommand } from "./commands/info.js"; import { initCommand } from "./commands/init.js"; import { loginCommand } from "./commands/login.js"; import { logoutCommand } from "./commands/logout.js"; +import { policyCommand } from "./commands/policy.js"; import { publishCommand } from "./commands/publish.js"; import { searchCommand } from "./commands/search.js"; import { switchCommand } from "./commands/switch.js"; @@ -56,6 +57,7 @@ const main = defineCommand({ dev: devCommand, bundle: bundleCommand, publish: publishCommand, + policy: policyCommand, "update-package": updatePackageCommand, validate: validateCommand, }, diff --git a/packages/plugin-cli/src/policy/api.ts b/packages/plugin-cli/src/policy/api.ts new file mode 100644 index 0000000000..b9bf63a70e --- /dev/null +++ b/packages/plugin-cli/src/policy/api.ts @@ -0,0 +1,337 @@ +import { ClientResponseError } from "@atcute/client"; +import { isDid } from "@atcute/lexicons/syntax"; +import { safeParse } from "@atcute/lexicons/validations"; +import type { PublishingClient } from "@emdash-cms/registry-client"; +import { NSID, PackageProfile, PackageProfileExtension } from "@emdash-cms/registry-lexicons"; + +export type ProfilePolicyErrorCode = + | "PROFILE_NOT_FOUND" + | "PROFILE_INVALID" + | "PROFILE_EXTENSION_INVALID" + | "REPOSITORY_REQUIRED" + | "REPOSITORY_ALREADY_SET" + | "INVALID_REPOSITORY" + | "INVALID_CONFIRMATION" + | "INVALID_APPROVERS" + | "STALE_RECORD" + | "LEXICON_VALIDATION_FAILED"; + +export class ProfilePolicyError extends Error { + override readonly name = "ProfilePolicyError"; + + constructor( + readonly code: ProfilePolicyErrorCode, + message: string, + readonly detail?: Record, + ) { + super(message); + } +} + +export interface ProfilePolicyInput { + repository?: string; + requireProvenance?: boolean; + confirmation?: string; + approvers?: string[]; +} + +export interface SetProfilePolicyOptions { + publisher: PublishingClient; + slug: string; + input: ProfilePolicyInput; + apply?: boolean; + now?: () => Date; +} + +export interface PolicyFieldDiff { + field: "repository" | "requireProvenance" | "confirmation" | "approvers"; + before: unknown; + after: unknown; +} + +export interface SetProfilePolicyResult { + profileUri: string; + diffs: PolicyFieldDiff[]; + candidate: Record; + written: boolean; + cid?: string; +} + +const CONFIRMATIONS = new Set(["escalation-only", "always"]); +const TRAILING_SLASHES_RE = /\/+$/; + +export async function setProfilePolicy( + options: SetProfilePolicyOptions, +): Promise { + const profileUri = `at://${options.publisher.did}/${NSID.packageProfile}/${options.slug}`; + const policyInput = { + ...options.input, + repository: + options.input.repository === undefined + ? undefined + : canonicaliseRepository(options.input.repository), + approvers: normaliseApprovers(options.input.approvers), + }; + validateInput(policyInput); + + let existing: { cid: string; value: unknown }; + try { + existing = await options.publisher.getRecord({ + collection: NSID.packageProfile, + rkey: options.slug, + }); + } catch (error) { + if (error instanceof ClientResponseError && error.error === "RecordNotFound") { + throw new ProfilePolicyError( + "PROFILE_NOT_FOUND", + `No package profile exists at ${profileUri}. Publish the package before setting its release policy.`, + { slug: options.slug }, + ); + } + throw error; + } + + if (!isPlainObject(existing.value) || !safeParse(PackageProfile.mainSchema, existing.value).ok) { + throw new ProfilePolicyError( + "PROFILE_INVALID", + `Existing profile at ${profileUri} does not match the package profile lexicon. Refusing to overwrite it.`, + { slug: options.slug }, + ); + } + + const { candidate, diffs } = buildProfilePolicyCandidate({ + existing: existing.value, + input: policyInput, + now: (options.now ?? (() => new Date()))(), + }); + + if (options.apply !== true || diffs.length === 0) { + return { profileUri, diffs, candidate, written: false }; + } + + const profileValidation = safeParse(PackageProfile.mainSchema, candidate); + const extension = getKnownExtension(candidate); + const extensionValidation = extension && safeParse(PackageProfileExtension.mainSchema, extension); + if (!profileValidation.ok || !extensionValidation?.ok || !isValidExtension(extension)) { + throw new ProfilePolicyError( + "LEXICON_VALIDATION_FAILED", + "The edited package profile or profile extension did not pass local validation. Nothing was written.", + { profile: profileValidation, extension: extensionValidation }, + ); + } + + try { + const put = await options.publisher.unsafePutRecord({ + collection: NSID.packageProfile, + rkey: options.slug, + record: candidate, + skipValidation: true, + swapRecord: existing.cid, + }); + return { profileUri, diffs, candidate, written: true, cid: put.cid }; + } catch (error) { + if (error instanceof ClientResponseError && error.error === "InvalidSwap") { + throw new ProfilePolicyError( + "STALE_RECORD", + `The package profile at ${profileUri} changed before the policy could be written. Re-run the command to review and apply the latest policy.`, + { slug: options.slug, expectedCid: existing.cid }, + ); + } + throw error; + } +} + +export function buildProfilePolicyCandidate(input: { + existing: Record; + input: ProfilePolicyInput; + now: Date; +}): { candidate: Record; diffs: PolicyFieldDiff[] } { + const existingExtensions = input.existing.extensions; + if (existingExtensions !== undefined && !isPlainObject(existingExtensions)) { + throw new ProfilePolicyError( + "PROFILE_INVALID", + "Existing profile extensions must be an object. Refusing to overwrite an unknown shape.", + ); + } + + const extensions = { ...existingExtensions }; + const current = extensions[NSID.packageProfileExtension]; + let extension: Record; + if (current === undefined) { + if (input.input.repository === undefined) { + throw new ProfilePolicyError( + "REPOSITORY_REQUIRED", + `This package profile has no ${NSID.packageProfileExtension} extension. Pass --repository to create its required repository anchor.`, + ); + } + extension = { $type: NSID.packageProfileExtension, repository: input.input.repository }; + } else { + if (!isPlainObject(current) || !isValidExistingExtension(current)) { + throw new ProfilePolicyError( + "PROFILE_EXTENSION_INVALID", + "The existing package profile extension must have a valid HTTPS repository anchor before its policy can be edited.", + ); + } + if (input.input.repository !== undefined) { + throw new ProfilePolicyError( + "REPOSITORY_ALREADY_SET", + "The package profile extension already has a repository anchor. Policy edits preserve it and cannot replace it.", + ); + } + extension = { ...current, $type: NSID.packageProfileExtension }; + } + + const currentPolicy = extension.releasePolicy; + if (currentPolicy !== undefined && !isPlainObject(currentPolicy)) { + throw new ProfilePolicyError( + "PROFILE_EXTENSION_INVALID", + "The existing package profile release policy must be an object. Refusing to overwrite an unknown shape.", + ); + } + const policy = { ...currentPolicy }; + const diffs: PolicyFieldDiff[] = []; + + if (current === undefined) { + diffs.push({ field: "repository", before: undefined, after: input.input.repository }); + } + if ( + input.input.requireProvenance !== undefined && + policy.requireProvenance !== input.input.requireProvenance + ) { + diffs.push({ + field: "requireProvenance", + before: policy.requireProvenance, + after: input.input.requireProvenance, + }); + policy.requireProvenance = input.input.requireProvenance; + } + if (input.input.confirmation !== undefined && policy.confirmation !== input.input.confirmation) { + diffs.push({ + field: "confirmation", + before: policy.confirmation, + after: input.input.confirmation, + }); + policy.confirmation = input.input.confirmation; + } + if ( + input.input.approvers !== undefined && + !sameStringSet(policy.approvers, input.input.approvers) + ) { + diffs.push({ field: "approvers", before: policy.approvers, after: input.input.approvers }); + policy.approvers = input.input.approvers; + } + + if (Object.keys(policy).length > 0) extension.releasePolicy = policy; + extensions[NSID.packageProfileExtension] = extension; + const candidate: Record = { ...input.existing, extensions }; + if (diffs.length > 0) candidate.lastUpdated = input.now.toISOString(); + return { candidate, diffs }; +} + +function validateInput(input: ProfilePolicyInput): void { + if (input.confirmation !== undefined && !CONFIRMATIONS.has(input.confirmation)) { + throw new ProfilePolicyError( + "INVALID_CONFIRMATION", + "--confirmation must be either escalation-only or always.", + ); + } + if (input.approvers !== undefined) { + if (input.approvers.length > 32) { + throw new ProfilePolicyError("INVALID_APPROVERS", "--approver accepts at most 32 DIDs."); + } + const seen = new Set(); + for (const did of input.approvers) { + if (!isDid(did)) { + throw new ProfilePolicyError("INVALID_APPROVERS", `Invalid approver DID: ${did}`); + } + if (seen.has(did)) { + throw new ProfilePolicyError("INVALID_APPROVERS", `Duplicate approver DID: ${did}`); + } + seen.add(did); + } + } +} + +function normaliseApprovers(approvers: string[] | undefined): string[] | undefined { + return approvers?.map((did) => did.trim()); +} + +function getKnownExtension(profile: Record): Record | null { + const extensions = profile.extensions; + if (!isPlainObject(extensions)) return null; + const extension = extensions[NSID.packageProfileExtension]; + return isPlainObject(extension) ? extension : null; +} + +function isValidExistingExtension(extension: Record): boolean { + return ( + safeParse(PackageProfileExtension.mainSchema, extension).ok && + typeof extension.repository === "string" && + canonicaliseExistingRepository(extension.repository) + ); +} + +function isValidExtension(extension: Record | null): boolean { + if (!extension || !isValidExistingExtension(extension)) return false; + const policy = extension.releasePolicy; + if (policy === undefined) return true; + if (!isPlainObject(policy)) return false; + if ( + policy.confirmation !== undefined && + (typeof policy.confirmation !== "string" || !CONFIRMATIONS.has(policy.confirmation)) + ) { + return false; + } + if (policy.approvers !== undefined) { + if ( + !Array.isArray(policy.approvers) || + new Set(policy.approvers).size !== policy.approvers.length + ) { + return false; + } + } + return true; +} + +/** + * Canonical form for the signed profile repository anchor. This deliberately + * leaves path case and a `.git` suffix alone: both can be meaningful to the + * source host, unlike the host name and trailing path separators. + */ +export function canonicaliseRepository(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + throw new Error("not a canonical repository URL"); + } + if (url.port) throw new Error("not a canonical repository URL"); + let path = url.pathname; + if (path !== "/") { + path = path.replace(TRAILING_SLASHES_RE, ""); + if (path === "") path = "/"; + } + return `https://${url.hostname.toLowerCase()}${path}`; + } catch { + throw new ProfilePolicyError( + "INVALID_REPOSITORY", + "--repository must be an HTTPS URL without userinfo, query, fragment, or a non-default port.", + ); + } +} + +function canonicaliseExistingRepository(value: string): boolean { + try { + return canonicaliseRepository(value) === value; + } catch { + return false; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function sameStringSet(existing: unknown, next: string[]): boolean { + if (!Array.isArray(existing) || existing.length !== next.length) return false; + return new Set(existing).size === existing.length && existing.every((did) => next.includes(did)); +} diff --git a/packages/plugin-cli/tests/policy-command.test.ts b/packages/plugin-cli/tests/policy-command.test.ts new file mode 100644 index 0000000000..f0157ed3f9 --- /dev/null +++ b/packages/plugin-cli/tests/policy-command.test.ts @@ -0,0 +1,109 @@ +import { parseArgs } from "citty"; +import { describe, expect, it } from "vitest"; + +import { + formatPolicyJsonError, + formatPolicyJsonResult, + policySetArgs, + resolvePolicyCommandInput, +} from "../src/commands/policy.js"; +import { ProfilePolicyError } from "../src/policy/api.js"; + +function resolve(rawArgs: string[]) { + return resolvePolicyCommandInput(parseArgs(rawArgs, policySetArgs), rawArgs); +} + +function expectErrorCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toMatchObject({ code }); + return; + } + throw new Error(`Expected ${code}`); +} + +describe("policy set command parsing", () => { + it("maps absent, enabled, and native-negated provenance flags", () => { + expect(resolve([]).input.requireProvenance).toBeUndefined(); + expect(resolve(["--require-provenance"]).input.requireProvenance).toBe(true); + expect(resolve(["--no-require-provenance"]).input.requireProvenance).toBe(false); + }); + + it.each([ + ["--require-provenance", "--require-provenance"], + ["--require-provenance", "--no-require-provenance"], + ["--no-require-provenance", "--no-require-provenance"], + ])("rejects repeated or contradictory provenance flags", (...rawArgs) => { + expectErrorCode(() => resolve(rawArgs), "INVALID_POLICY_FLAGS"); + }); + + it("rejects an array-valued provenance flag instead of treating it as absent", () => { + expectErrorCode( + () => resolvePolicyCommandInput({ "require-provenance": [true, false] }, []), + "INVALID_POLICY_FLAGS", + ); + }); + + it("keeps Citty's repeated approver values as the replacement list", () => { + expect( + resolve(["--approver", "did:plc:alice", "--approver", "did:plc:bob"]).input.approvers, + ).toEqual(["did:plc:alice", "did:plc:bob"]); + }); + + it("defaults to a dry-run, applies with --yes, and supports clearing approvers", () => { + expect(resolve([]).apply).toBe(false); + expect(resolve(["--yes"]).apply).toBe(true); + expect(resolve(["--clear-approvers"]).input.approvers).toEqual([]); + expectErrorCode( + () => resolve(["--clear-approvers", "--approver", "did:plc:alice"]), + "INVALID_POLICY_FLAGS", + ); + }); + + it("rejects repeated clear-approvers flags", () => { + expectErrorCode( + () => resolve(["--clear-approvers", "--clear-approvers"]), + "INVALID_POLICY_FLAGS", + ); + }); + + it("formats stable JSON success and error envelopes", () => { + expect( + formatPolicyJsonResult( + { profileUri: "at://did:plc:test/profile/test", written: false, diffs: [], candidate: {} }, + false, + ), + ).toEqual({ + profile: "at://did:plc:test/profile/test", + written: false, + applied: false, + diffs: [], + }); + expect( + formatPolicyJsonResult( + { + profileUri: "at://did:plc:test/profile/test", + written: false, + diffs: [{ field: "repository", before: undefined, after: "https://example.com/repo" }], + candidate: {}, + }, + false, + ), + ).toMatchObject({ + diffs: [{ field: "repository", before: null, after: "https://example.com/repo" }], + }); + const stale = new ProfilePolicyError("STALE_RECORD", "stale", { expectedCid: "bafyold" }); + const errorEnvelope = formatPolicyJsonError(stale.code, stale.message, stale.detail); + expect(errorEnvelope).toEqual({ + error: { code: "STALE_RECORD", message: "stale", detail: { expectedCid: "bafyold" } }, + }); + const stdout = `${JSON.stringify(errorEnvelope)}\n`; + expect(stdout).toBe( + '{"error":{"code":"STALE_RECORD","message":"stale","detail":{"expectedCid":"bafyold"}}}\n', + ); + expect(formatPolicyJsonError("INVALID_POLICY_FLAGS", "bad flags")).toEqual({ + error: { code: "INVALID_POLICY_FLAGS", message: "bad flags" }, + }); + }); +}); diff --git a/packages/plugin-cli/tests/policy.test.ts b/packages/plugin-cli/tests/policy.test.ts new file mode 100644 index 0000000000..51fdc9091e --- /dev/null +++ b/packages/plugin-cli/tests/policy.test.ts @@ -0,0 +1,298 @@ +import { PublishingClient } from "@emdash-cms/registry-client"; +import type { Did } from "@emdash-cms/registry-client"; +import { NSID } from "@emdash-cms/registry-lexicons"; +import { describe, expect, it } from "vitest"; + +import { canonicaliseRepository, ProfilePolicyError, setProfilePolicy } from "../src/policy/api.js"; +import { MockPds } from "./mock-pds.js"; + +const DID: Did = "did:plc:test123"; +const SLUG = "test-plugin"; +const NOW = new Date("2026-07-10T12:00:00.000Z"); + +function publisher(pds: MockPds): PublishingClient { + return PublishingClient.fromHandler({ handler: pds, did: pds.did, pds: "http://mock.test" }); +} + +function seedProfile(pds: MockPds, overrides: Record = {}) { + pds.seedRecord(NSID.packageProfile, SLUG, { + $type: NSID.packageProfile, + id: `at://${DID}/${NSID.packageProfile}/${SLUG}`, + type: "emdash-plugin", + license: "MIT", + authors: [{ name: "Alice" }], + security: [{ email: "security@example.com" }], + slug: SLUG, + lastUpdated: "2024-01-01T00:00:00.000Z", + ...overrides, + }); +} + +function set(pds: MockPds, input: Parameters[0]["input"], apply = false) { + return setProfilePolicy({ publisher: publisher(pds), slug: SLUG, input, apply, now: () => NOW }); +} + +function expectErrorCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toMatchObject({ code }); + return; + } + throw new Error(`Expected ${code}`); +} + +describe("setProfilePolicy", () => { + it("dry-runs policy changes without writing", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + const result = await set(pds, { requireProvenance: true }); + expect(result.written).toBe(false); + expect(result.candidate.lastUpdated).toBe(NOW.toISOString()); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); + + it("applies with CAS and preserves unknown profile and extension data", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + futureField: { preserved: true }, + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + future: { keep: true }, + }, + "com.example.other": { $type: "com.example.other", exact: ["keep"] }, + }, + }); + const result = await set(pds, { requireProvenance: true }, true); + expect(result.written).toBe(true); + const put = pds.callsTo("com.atproto.repo.putRecord")[0]!.body as { + swapRecord?: string; + validate?: boolean; + record: Record; + }; + expect(put.swapRecord).toBeTruthy(); + expect(put.validate).toBe(false); + expect(put.record.futureField).toEqual({ preserved: true }); + const extensions = put.record.extensions as Record>; + expect(extensions["com.example.other"]).toEqual({ + $type: "com.example.other", + exact: ["keep"], + }); + expect(extensions[NSID.packageProfileExtension]).toMatchObject({ + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/plugin", + future: { keep: true }, + releasePolicy: { requireProvenance: true }, + }); + }); + + it("requires a repository to create an extension and preserves it later", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds); + await expect(set(pds, { requireProvenance: true })).rejects.toMatchObject({ + code: "REPOSITORY_REQUIRED", + }); + const created = await set( + pds, + { repository: "https://github.com/example/plugin", requireProvenance: true }, + true, + ); + expect(created.diffs).toContainEqual({ + field: "repository", + before: undefined, + after: "https://github.com/example/plugin", + }); + await set(pds, { confirmation: "always" }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .repository, + ).toBe("https://github.com/example/plugin"); + }); + + it("writes a strict policy exactly", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await set( + pds, + { requireProvenance: true, confirmation: "always", approvers: ["did:plc:alice"] }, + true, + ); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ requireProvenance: true, confirmation: "always", approvers: ["did:plc:alice"] }); + }); + + it("normalizes approver DIDs before storing and detecting duplicates", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await set(pds, { approvers: [" did:plc:alice "] }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ approvers: ["did:plc:alice"] }); + await expect( + set(pds, { approvers: ["did:plc:alice", " did:plc:alice "] }), + ).rejects.toMatchObject({ code: "INVALID_APPROVERS" }); + }); + + it("treats the same approvers in a different order as a no-op", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { approvers: ["did:plc:alice", "did:plc:bob"] }, + }, + }, + }); + const result = await set(pds, { approvers: ["did:plc:bob", "did:plc:alice"] }, true); + expect(result.diffs).toEqual([]); + expect(result.written).toBe(false); + }); + + it("writes an explicit empty approver list", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { approvers: ["did:plc:alice"] }, + }, + }, + }); + await set(pds, { approvers: [] }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .releasePolicy, + ).toEqual({ approvers: [] }); + }); + + it.each([ + [{ confirmation: "manual-review" }, "INVALID_CONFIRMATION"], + [{ repository: "http://example.com/repo" }, "INVALID_REPOSITORY"], + [{ approvers: ["not-a-did"] }, "INVALID_APPROVERS"], + [{ approvers: ["did:plc:alice", "did:plc:alice"] }, "INVALID_APPROVERS"], + ] as const)("rejects invalid policy input before writing", async (input, code) => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + await expect(set(pds, input)).rejects.toMatchObject({ code }); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); + + it("canonicalizes a new repository anchor before writing", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds); + await set(pds, { repository: "https://GitHub.com/Example/Plugin///" }, true); + const stored = pds.records.get(`at://${DID}/${NSID.packageProfile}/${SLUG}`)!.value as Record< + string, + unknown + >; + expect( + (stored.extensions as Record>)[NSID.packageProfileExtension] + .repository, + ).toBe("https://github.com/Example/Plugin"); + }); + + it.each([ + ["http://example.com/repo"], + ["https://user@example.com/repo"], + ["https://example.com/repo?ref=main"], + ["https://example.com/repo#readme"], + ["https://example.com:8443/repo"], + ] as const)("rejects a non-canonicalizable repository input", (repository) => { + expectErrorCode(() => canonicaliseRepository(repository), "INVALID_REPOSITORY"); + }); + + it("preserves root slashes and path case when canonicalizing repositories", () => { + expect(canonicaliseRepository("https://EXAMPLE.com/")).toBe("https://example.com/"); + expect(canonicaliseRepository("https://EXAMPLE.com///")).toBe("https://example.com/"); + expect(canonicaliseRepository("https://EXAMPLE.com/Owner/Repo.git/")).toBe( + "https://example.com/Owner/Repo.git", + ); + }); + + it("refuses a non-canonical existing repository anchor", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://GitHub.com/example/plugin/" }, + }, + }); + await expect(set(pds, { requireProvenance: true })).rejects.toMatchObject({ + code: "PROFILE_EXTENSION_INVALID", + }); + }); + + it("maps a stale CAS write to a stable error", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { repository: "https://github.com/example/plugin" }, + }, + }); + const handle = pds.handle.bind(pds); + pds.handle = async (pathname, init) => + pathname.includes("putRecord") + ? new Response(JSON.stringify({ error: "InvalidSwap" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + : handle(pathname, init); + await expect(set(pds, { requireProvenance: true }, true)).rejects.toBeInstanceOf( + ProfilePolicyError, + ); + await expect(set(pds, { requireProvenance: true }, true)).rejects.toMatchObject({ + code: "STALE_RECORD", + }); + }); + + it("does not write or change lastUpdated for a no-op apply", async () => { + const pds = new MockPds({ did: DID }); + seedProfile(pds, { + extensions: { + [NSID.packageProfileExtension]: { + repository: "https://github.com/example/plugin", + releasePolicy: { requireProvenance: true }, + }, + }, + }); + const result = await set(pds, { requireProvenance: true }, true); + expect(result.written).toBe(false); + expect(result.candidate.lastUpdated).toBe("2024-01-01T00:00:00.000Z"); + expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); + }); +});