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
13 changes: 13 additions & 0 deletions .changeset/clean-otters-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@emdash-cms/registry-client": minor
---

Adds `DirectPdsClient.getPackageRepository()` for reading a package profile and every package release from one proof-verified AT Protocol repository export.

Use the method when authorization or version selection requires a complete signed package snapshot:

```ts
const { profile, releases } = await directPdsClient.getPackageRepository("gallery");
```

The client verifies the repository commit signature, record blocks, and complete Merkle search tree before returning records. Unsigned `repo.getRecord` and `repo.listRecords` envelopes cannot substitute or omit package data. Repository exports use the client's `maxResponseBytes` limit, which defaults to 5 MiB, and a missing export reports `REPOSITORY_NOT_FOUND`.
106 changes: 36 additions & 70 deletions apps/release-service/src/approvals/authority.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { ActorResolver } from "@atcute/identity-resolver";
import { safeParse } from "@atcute/lexicons";
import { isDid } from "@atcute/lexicons/syntax";
import { NSID, PackageProfile, PackageProfileExtension } from "@emdash-cms/registry-lexicons";
import {
DirectPdsClient,
DirectPdsReadError,
type DirectPdsDidDocumentResolver,
} from "@emdash-cms/registry-client/direct-pds";
import { NSID, PackageProfileExtension } from "@emdash-cms/registry-lexicons";
import { fetchVerifiedResource } from "@emdash-cms/registry-verification/fetch";

import { createWorkerActorResolver } from "../oauth/custody.js";
import type {
IntentTransition,
PublisherDurableObject,
Expand Down Expand Up @@ -39,10 +42,11 @@ export interface LoadedApprovalIntent {
appliedDecision: "approve" | "reject" | null;
appliedApproverDid: string | null;
appliedApprovalDigest: string | null;
approverDids: readonly string[];
}

export interface VerifyCurrentApproverOptions {
actorResolver?: ActorResolver;
didDocumentResolver?: DirectPdsDidDocumentResolver;
fetch?: typeof globalThis.fetch;
}

Expand Down Expand Up @@ -116,6 +120,7 @@ export async function loadApprovalIntent(
appliedDecision,
appliedApproverDid: appliedDecision ? (decisionTransition?.actorIdentity ?? null) : null,
appliedApprovalDigest: appliedDecision ? (decisionTransition?.transitionDigest ?? null) : null,
approverDids: state.approverDids,
};
}

Expand Down Expand Up @@ -193,39 +198,20 @@ async function resolvePublicHostname(
return [...ipv4, ...ipv6];
}

function profileRecordUrl(pds: string, publisherDid: string, packageSlug: string): URL {
let url: URL;
try {
url = new URL(pds);
} catch {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
if (
url.protocol !== "https:" ||
url.username !== "" ||
url.password !== "" ||
url.pathname !== "/" ||
url.search !== "" ||
url.hash !== ""
) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
url.pathname = "/xrpc/com.atproto.repo.getRecord";
url.searchParams.set("repo", publisherDid);
url.searchParams.set("collection", NSID.packageProfile);
url.searchParams.set("rkey", packageSlug);
return url;
}

function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof fetch {
return async (input, init) => {
const requestedUrl = new URL(input instanceof Request ? input.url : input.toString());
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
if (method !== "GET") {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
const resource = await fetchVerifiedResource(requestedUrl, {
fetch: (url, requestInit) => fetchImplementation(url, requestInit),
fetch: (url, requestInit) =>
fetchImplementation(url, {
...requestInit,
...(headers === undefined ? {} : { headers }),
}),
resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation),
headerTimeoutMs: 10_000,
totalTimeoutMs: 30_000,
Expand All @@ -244,12 +230,16 @@ function createGuardedIdentityFetch(fetchImplementation: typeof fetch): typeof f

export async function verifyCurrentApprover(
evidence: ApprovalEvidence,
immutableApproverDids: readonly string[],
approverDid: string,
options: VerifyCurrentApproverOptions = {},
): Promise<void> {
if (!isDid(evidence.publisherDid) || !isDid(approverDid)) {
throw new ApprovalAuthorityError("APPROVAL_EVIDENCE_INVALID");
}
if (!immutableApproverDids.includes(approverDid)) {
throw new ApprovalAuthorityError("APPROVER_NOT_AUTHORIZED");
}
const policy = await loadCurrentApprovalPolicy(
evidence.publisherDid,
evidence.packageSlug,
Expand All @@ -272,52 +262,28 @@ export async function loadCurrentApprovalPolicy(
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const fetchImplementation = options.fetch ?? globalThis.fetch;
let actor;
let record;
try {
actor = await (
options.actorResolver ??
createWorkerActorResolver(createGuardedIdentityFetch(fetchImplementation))
).resolve(publisherDid, { signal: AbortSignal.timeout(30_000), noCache: true });
} catch {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
if (actor.did !== publisherDid) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const requestedUrl = profileRecordUrl(actor.pds, publisherDid, packageSlug);
const resource = await fetchVerifiedResource(requestedUrl, {
fetch: (url, init) => fetchImplementation(url, init),
resolveHostname: (hostname) => resolvePublicHostname(hostname, fetchImplementation),
headerTimeoutMs: 10_000,
totalTimeoutMs: 30_000,
maxBytes: MAX_PROFILE_RESPONSE_BYTES,
maxRedirects: 1,
});
if (!resource.success || resource.value.url.toString() !== requestedUrl.toString()) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
let envelope: unknown;
try {
envelope = JSON.parse(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(resource.value.bytes),
);
} catch {
record = await new DirectPdsClient({
did: publisherDid,
fetch: createGuardedIdentityFetch(fetchImplementation),
...(options.didDocumentResolver === undefined
? {}
: { didDocumentResolver: options.didDocumentResolver }),
requestTimeoutMs: 30_000,
maxResponseBytes: MAX_PROFILE_RESPONSE_BYTES,
}).getPackageProfile(packageSlug);
} catch (error) {
if (error instanceof DirectPdsReadError || error instanceof TypeError) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const expectedUri = `at://${publisherDid}/${NSID.packageProfile}/${packageSlug}`;
if (
!isRecord(envelope) ||
envelope["uri"] !== expectedUri ||
typeof envelope["cid"] !== "string" ||
!("value" in envelope)
) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const profile = safeParse(PackageProfile.mainSchema, envelope["value"]);
if (!profile.ok || profile.value.id !== expectedUri) {
if (record.uri !== expectedUri || record.value.id !== expectedUri) {
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
const rawExtension = profile.value.extensions?.[NSID.packageProfileExtension];
const rawExtension = record.value.extensions?.[NSID.packageProfileExtension];
const extension = safeParse(PackageProfileExtension.mainSchema, rawExtension);
if (!extension.ok) throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
const approverDids = extension.value.releasePolicy?.approvers ?? [];
Expand All @@ -328,7 +294,7 @@ export async function loadCurrentApprovalPolicy(
throw new ApprovalAuthorityError("PROFILE_FETCH_FAILED");
}
return {
profileCid: envelope["cid"],
profileCid: record.cid,
approverDids: [...approverDids].toSorted(),
};
}
8 changes: 4 additions & 4 deletions apps/release-service/src/approvals/decision-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export async function handleGetApproval(
publisherDid(request),
intentId(params),
);
await verifyCurrentApprover(loaded.evidence, session.approverDid);
await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid);
const policyDecision = await env.PUBLISHER_DO.getByName(
loaded.evidence.publisherDid,
).getVerificationStep(loaded.evidence.publisherDid, loaded.intent.id, "policy-decision");
Expand Down Expand Up @@ -479,7 +479,7 @@ export async function handleBeginApprovalDecision(
if (loaded.intent.state !== "awaiting_approval") {
throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE");
}
await verifyCurrentApprover(loaded.evidence, session.approverDid);
await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid);
const result = await beginApprovalDecision(
env.APPROVER_DO.getByName(session.approverDid),
{
Expand Down Expand Up @@ -529,7 +529,7 @@ export async function handleCompleteApprovalDecision(
throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE");
}
if (!alreadyApplied) {
await verifyCurrentApprover(loaded.evidence, session.approverDid);
await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid);
}
const result = await completeApprovalDecision(
env.APPROVER_DO.getByName(session.approverDid),
Expand Down Expand Up @@ -563,7 +563,7 @@ export async function handleCompleteApprovalDecision(
}
return apiSuccess({ receipt: result.receipt, intent: loaded.intent }, requestId);
}
await verifyCurrentApprover(loaded.evidence, session.approverDid);
await verifyCurrentApprover(loaded.evidence, loaded.approverDids, session.approverDid);
if (loaded.intent.expiresAt <= Date.now()) {
throw new ApprovalAuthorityError("INTENT_NOT_APPROVABLE");
}
Expand Down
Loading
Loading