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
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
* claim that those exact bytes occupy the requested native address.
*/
import { NextRequest, NextResponse } from "next/server";
import { deriveAnchorAddress, readAnchor } from "@/src/catalog/chain";
import { findProgramAddress } from "@/src/catalog/store";
import { readAnchor } from "@/src/catalog/chain";
import { resolveProgramAddress } from "@/src/catalog/store";

export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams;
let ref = q.get("ref");
const owner = q.get("owner");
const name = q.get("name");
if (!ref && owner && name) {
ref = findProgramAddress(owner, name) ?? deriveAnchorAddress(owner, name);
ref = resolveProgramAddress(owner, name);
if (!ref) {
return NextResponse.json({ error: "more than one program uses this owner and name; the artifact is indeterminate" }, { status: 409 });
}
}
if (!ref) return NextResponse.json({ error: "need ?ref= or ?owner=&name=" }, { status: 400 });
const value = await readAnchor(ref);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
/** GET /api/dacs/deal-owners?jobId= — catalog lookup for a deal's anchor owners
/** GET /api/dacs/deal-owners?jobId=[&bundleRef=] — catalog lookup for a deal's anchor owners
* (used when a bundle copy doesn't name the buyer, e.g. seller-anchored copies). */
import { NextRequest, NextResponse } from "next/server";
import { loadCatalog, loadScanState } from "@/src/catalog/store";
import { canonicalDemosAgentClaim } from "@/src/catalog/claimRef";
import { ownArray } from "@/src/catalog/scanState";
import { artifactOwner, canonicalProgramOwner, loadCatalog, loadScanState } from "@/src/catalog/store";
import type { RegisteredDeal } from "@/src/catalog/types";

const party = (claim: string) => claim ? canonicalProgramOwner(claim) : "";
const parties = (deal: RegisteredDeal) => `${party(deal.owners.buyer)}\n${party(deal.owners.seller)}`;
/** A deal without an attributed seller has no seller copy. */
const sellerRef = (deal: RegisteredDeal) => deal.owners.seller ? deal.sellerBundleRef : undefined;

/**
* A jobId is unique only per producer, so every catalog and scanned entry for it is
* considered. An entry counts only when each bundle ref it carries is bound to the party
* it is reported for: that party owns the storage program, or that party's verified
* BundleBinding for the role names the address. A catalog entry also has to sit in its
* own seller's record and either have both copies verified against these parties, or be
* a scanned entry as indexed: the same parties, each ref the scanned one or resolved
* through that party's binding. `bundleRef` then narrows the entries to the deal holding
* that copy. Owners are reported only when exactly one distinct, attributed deal remains.
*/
export async function GET(req: NextRequest) {
const jobId = req.nextUrl.searchParams.get("jobId")?.trim();
if (!jobId) return NextResponse.json({ error: "need ?jobId=" }, { status: 400 });
for (const s of loadCatalog().sellers) {
const d = s.deals.find((x) => x.jobId === jobId);
if (d) {
return NextResponse.json({
owners: d.owners,
buyerBundleRef: d.buyerBundleRef,
sellerBundleRef: d.sellerBundleRef ?? null,
});
}
const bundleRef = req.nextUrl.searchParams.get("bundleRef")?.trim() || undefined;
const state = loadScanState();
const bindings = ownArray(state.bundleBindings, jobId);
const signedFor = (ref: string, claim: string, role: "buyer" | "seller") => {
const holder = canonicalDemosAgentClaim(claim);
return holder !== null && bindings.some((binding) =>
binding.role === role && binding.nativeAddress === ref && canonicalDemosAgentClaim(binding.signer) === holder);
};
const boundTo = (ref: string, claim: string, role: "buyer" | "seller") =>
canonicalDemosAgentClaim(claim) !== null && (artifactOwner(ref) === party(claim) || signedFor(ref, claim, role));
const bound = (deal: RegisteredDeal) => boundTo(deal.buyerBundleRef, deal.owners.buyer, "buyer") &&
(!sellerRef(deal) || boundTo(sellerRef(deal)!, deal.owners.seller, "seller"));
const entries = Object.values(state.deals).filter((deal) => deal.jobId === jobId);
const indexedFrom = (deal: RegisteredDeal) => entries.some((entry) => parties(entry) === parties(deal) &&
(deal.buyerBundleRef === entry.buyerBundleRef || signedFor(deal.buyerBundleRef, deal.owners.buyer, "buyer")) &&
(sellerRef(deal) === sellerRef(entry) || (!!sellerRef(deal) && signedFor(sellerRef(deal)!, deal.owners.seller, "seller"))));
const catalog = loadCatalog().sellers.flatMap((record) => record.deals.filter((deal) => deal.jobId === jobId &&
canonicalDemosAgentClaim(deal.owners.seller) !== null &&
canonicalDemosAgentClaim(deal.owners.seller) === canonicalDemosAgentClaim(record.primaryClaim) &&
(deal.refsVerified || indexedFrom(deal)) && bound(deal)));
// A scanned entry already indexed into the catalog is represented by its catalog entry,
// whose bundle refs may have been resolved through bindings, whichever ref is requested.
const indexed = new Set(catalog.map(parties));
const scanned = entries.filter((deal) => !indexed.has(parties(deal)) && bound(deal));
const holds = (deal: RegisteredDeal) => !bundleRef || deal.buyerBundleRef === bundleRef || sellerRef(deal) === bundleRef;
const distinct = new Map<string, RegisteredDeal>();
for (const deal of [...catalog, ...scanned].filter(holds)) {
distinct.set(JSON.stringify([party(deal.owners.buyer), party(deal.owners.seller), deal.buyerBundleRef, sellerRef(deal) ?? null]), deal);
}
const scanned = loadScanState().deals[jobId];
if (scanned) {
const [only] = distinct.values();
if (distinct.size === 1 && only.owners.seller) {
return NextResponse.json({
owners: scanned.owners,
buyerBundleRef: scanned.buyerBundleRef,
sellerBundleRef: scanned.sellerBundleRef ?? null,
owners: only.owners,
buyerBundleRef: only.buyerBundleRef,
sellerBundleRef: sellerRef(only) ?? null,
});
}
return NextResponse.json({ owners: null, buyerBundleRef: null });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { Metadata } from "next";
import CopyText from "@/src/components/CopyText";
import VerifyAttestation from "@/src/components/VerifyAttestation";
import VerifyDeal from "@/src/components/VerifyDeal";
import { deriveAnchorAddress, readAnchor } from "@/src/catalog/chain";
import { findProgramAddress } from "@/src/catalog/store";
import { readAnchor } from "@/src/catalog/chain";
import { resolveProgramAddress } from "@/src/catalog/store";

const EXPLORER = "https://explorer.demos.sh";

Expand Down Expand Up @@ -45,7 +45,7 @@ export default async function Deal({
const deliveryName = `dacsx:delivery:${jobId}`;
const sellerClaim = seller ? decodeURIComponent(seller) : null;
const attestationAddress = sellerClaim && jobId
? findProgramAddress(sellerClaim, deliveryName) ?? deriveAnchorAddress(sellerClaim, deliveryName)
? resolveProgramAddress(sellerClaim, deliveryName)
: null;
const attestation = attestationAddress ? await readAnchor(attestationAddress) : null;
const att = attestation as {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function VerifyPage() {

// 2. seller-anchored copy → the catalog knows the deal's owners.
if (!buyer && jobId) {
const o = await fetch(`/api/dacs/deal-owners?jobId=${encodeURIComponent(jobId)}`).then((r) => r.json());
const o = await fetch(`/api/dacs/deal-owners?jobId=${encodeURIComponent(jobId)}&bundleRef=${encodeURIComponent(address)}`).then((r) => r.json());
buyer = o?.owners?.buyer ?? null;
}
// 3. manual override as last resort.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export function bundleBindingRoleKey(jobId: string, role: BundleBinding["role"])
return `${jobId}\n${role}`;
}

/** Discovery is bounded per authenticated signer, so one signer's bindings never use another's budget (BB-6). */
export function bundleBindingOverflowKey(jobId: string, role: BundleBinding["role"], signer: string): string {
return `${bundleBindingRoleKey(jobId, role)}\n${canonicalDemosAgentClaim(signer) ?? signer}`;
}

function strictSignatureBytes(value: unknown): Uint8Array | null {
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{86}$/.test(value)) return null;
try {
Expand Down Expand Up @@ -107,8 +112,9 @@ const bindingOrder = (left: BundleBinding, right: BundleBinding): number =>
contentHash(left).localeCompare(contentHash(right));

/**
* Deterministic total-work ceiling. Overflow is sticky in ScanState and must
* make that side indeterminate; truncation can never manufacture absence.
* Deterministic work ceiling per signer of each jobId and role. Overflow is
* sticky in ScanState and must make that signer's side indeterminate;
* truncation can never manufacture absence.
*/
export function boundedBundleBindings(
bindings: Iterable<BundleBinding>,
Expand All @@ -118,7 +124,7 @@ export function boundedBundleBindings(
for (const binding of bindings) unique.set(contentHash(binding), binding);
const byRole = new Map<string, BundleBinding[]>();
for (const binding of unique.values()) {
const key = bundleBindingRoleKey(binding.jobId, binding.role);
const key = bundleBindingOverflowKey(binding.jobId, binding.role, binding.signer);
const values = byRole.get(key) ?? [];
values.push(binding);
byRole.set(key, values);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,20 @@ export function bundleMatchesRegisteredDeal(
sellers[0].primaryClaim === catalogSeller;
}

/** Keep at most one verified reputation record per signed job and bundle ref. */
/**
* Keep at most one verified reputation record per buyer and signed job, and per
* bundle ref. A jobId is unique only per producer, so two buyers' deals with
* the same jobId are distinct records.
*/
export function dedupeVerifiedDeals(deals: DealRecord[]): DealRecord[] {
const jobs = new Set<string>();
const refs = new Set<string>();
return deals.filter((deal) => {
if (!deal.refsVerified) return true;
const job = `${demosSigningIdentity(deal.owners.buyer)}\n${deal.jobId}`;
const ref = deal.buyerBundleRef.toLowerCase();
if (jobs.has(deal.jobId) || refs.has(ref)) return false;
jobs.add(deal.jobId);
if (jobs.has(job) || refs.has(ref)) return false;
jobs.add(job);
refs.add(ref);
return true;
});
Expand Down
6 changes: 3 additions & 3 deletions reference-implementations/dacs-directory/src/catalog/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export async function resolveOwnedAnchorByName(

let candidates: ProgramCandidate[];
try {
const body = await response.json() as { result?: unknown; response?: unknown };
const body = await storageResponseJson(response) as { result?: unknown; response?: unknown };
if (body.result !== 200 || !Array.isArray(body.response)) {
return { status: "indeterminate", reason: "program-name lookup returned an invalid response" };
}
Expand Down Expand Up @@ -154,15 +154,15 @@ export async function resolveOwnedAnchorByName(
}
let record: AnchorRecord | null = null;
try {
const body = await read.json() as {
const body = await storageResponseJson(read) as {
success?: boolean;
data?: Record<string, unknown>;
owner?: string;
programName?: string;
};
if (
body.success && body.data != null && typeof body.data === "object" && !Array.isArray(body.data) &&
body.programName === programName
boundedJson(body.data) !== null && body.programName === programName
) {
record = { data: body.data, owner: body.owner, programName: body.programName };
}
Expand Down
60 changes: 45 additions & 15 deletions reference-implementations/dacs-directory/src/catalog/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,27 @@ import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleC
// The SDK doesn't export sessionAnchorName from its public barrel
// (dacs-sdk#14) — reach into the vendored build.
import { sessionAnchorName } from "../../vendor/dacs-sdk/dist/agent/runSessionCore.js";
import { deriveAnchorAddress, readAnchor, readAnchorRecord } from "./chain.js";
import { readAnchor, readAnchorRecord } from "./chain.js";
import { gcrGetIdentities } from "./gcr.js";
import { findValidListingRevocation, ownerClaim, verifyListing } from "./listingVerification.js";
import {
findValidListingRevocation,
ownerClaim,
REVOCATION_VERIFICATIONS_PER_PASS,
verifyListing,
type RevocationCandidateProgress,
} from "./listingVerification.js";
import { canonicalDemosAgentClaim } from "./claimRef.js";
import { resolveDemosPrimaryClaimKey } from "./primaryClaimKey.js";
import { listingPresentation } from "./listingMetadata.js";
import { verifyOwnerSignature } from "./registrationSig.js";
import {
artifactAnchorTime,
clearListingRejection,
findProgramAddress,
nextRevocationCandidates,
resolveProgramAddress,
loadScanState,
recordListingRejection,
recordRevocationProgress,
type ListingRejectionCode,
throwIfInfrastructureError,
} from "./store.js";
Expand All @@ -45,7 +53,7 @@ import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph } from ".
import { agreementRail } from "./agreementMetadata.js";
import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "./currentReconciliation.js";
import {
bundleBindingRoleKey,
bundleBindingOverflowKey,
resolveBundleSide,
verifyBundleBinding,
} from "./bundleBinding.js";
Expand Down Expand Up @@ -139,7 +147,7 @@ export async function indexRegistration(
const listingArtifacts = new Map<string, { locator: string; raw: Record<string, unknown> }>();
let identityBundle: Record<string, unknown> | undefined;
const identityBundles: Record<string, unknown>[] = [];
const revocations = loadScanState().revocations ?? {};
const { verifiedRevocations } = loadScanState();
for (const anchor of reg.listingAnchors) {
const anchored = await readAnchorRecord(anchor);
if (!anchored) continue;
Expand Down Expand Up @@ -168,16 +176,24 @@ export async function indexRegistration(
: 1;
const validity = scope.validity as { notAfter?: unknown } | undefined;
if (typeof validity?.notAfter === "number" && validity.notAfter < now) continue;
const storedCandidates = revocations[verified.contentHash];
const revocationAddresses = Array.isArray(storedCandidates)
? storedCandidates
: storedCandidates ? [storedCandidates] : [];
// Verification work is bounded per pass: markers already verified come first (even when
// an older snapshot omitted them from the queue), then the next pending candidates,
// those anchored by the listing's own owner first. Only one batch is ever selected.
const limit = REVOCATION_VERIFICATIONS_PER_PASS;
const progress: RevocationCandidateProgress = { unread: [], rejected: [] };
const revocationBinding = await findValidListingRevocation(
revocationAddresses,
[
...(verifiedRevocations[verified.contentHash] ?? []).slice(0, limit),
...nextRevocationCandidates(verified.contentHash, anchored.owner ?? null, limit),
],
verified,
version,
readAnchor,
undefined,
progress,
limit,
);
recordRevocationProgress(verified.contentHash, progress);
const presentation = listingPresentation(scope);
const signedSeller = scope.seller && typeof scope.seller === "object" && !Array.isArray(scope.seller)
? scope.seller as Record<string, unknown> : null;
Expand Down Expand Up @@ -223,15 +239,28 @@ export async function indexRegistration(
const scanState = loadScanState();
const overflowBindings = new Set(scanState.bundleBindingOverflow ?? []);
const relevantJobs = new Set((reg.deals ?? []).map((deal) => deal.jobId));
// Only bindings signed by a deal's own buyer or seller are verified, so other signers'
// bindings for the same jobId add no work here (BB-6).
const holders = new Set((reg.deals ?? []).flatMap((deal) => [deal.owners.buyer, deal.owners.seller]
.map((claim) => `${deal.jobId}\n${canonicalDemosAgentClaim(claim)}`)));
const signedByHolder = (binding: BundleBinding) => typeof binding?.signer === "string" &&
canonicalDemosAgentClaim(binding.signer) !== null && holders.has(`${binding.jobId}\n${canonicalDemosAgentClaim(binding.signer)}`);
const rawBindings = [
...[...relevantJobs].flatMap((jobId) => ownArray(scanState.bundleBindings, jobId)),
...(reg.bundleBindings ?? []).filter((binding) => relevantJobs.has(binding.jobId)),
];
].filter(signedByHolder);
const verifiedBindings = (await Promise.all(rawBindings.map((binding) => verifyBundleBinding(binding))))
.filter((binding): binding is BundleBinding => binding !== null);
onOmitted?.("bindings", rawBindings.length - verifiedBindings.length);
const indexDeal = async (deal: RegisteredDeal): Promise<void> => {
const jobBindings = verifiedBindings.filter((binding) => binding.jobId === deal.jobId);
// Only bindings signed by this deal's role holders are discovery for it (BB-6).
const roleHolder = (role: string) => role === "buyer" ? deal.owners.buyer : role === "seller" ? deal.owners.seller : null;
const jobBindings = verifiedBindings.filter((binding) => {
const holder = roleHolder(binding.role);
return binding.jobId === deal.jobId && holder !== null &&
canonicalDemosAgentClaim(binding.signer) !== null &&
canonicalDemosAgentClaim(binding.signer) === canonicalDemosAgentClaim(holder);
});
const buyerInitial = await readAnchor(deal.buyerBundleRef);
const resolveListing = async (ref: Record<string, unknown>) => {
const id = String(ref.listingId ?? "");
Expand Down Expand Up @@ -295,15 +324,15 @@ export async function indexRegistration(
role: "buyer",
expectedSigner: deal.owners.buyer,
bindings: jobBindings,
overflow: overflowBindings.has(bundleBindingRoleKey(deal.jobId, "buyer")),
overflow: overflowBindings.has(bundleBindingOverflowKey(deal.jobId, "buyer", deal.owners.buyer)),
inspect,
});
const sellerResolution = await resolveBundleSide({
jobId: deal.jobId,
role: "seller",
expectedSigner: deal.owners.seller,
bindings: jobBindings,
overflow: overflowBindings.has(bundleBindingRoleKey(deal.jobId, "seller")),
overflow: overflowBindings.has(bundleBindingOverflowKey(deal.jobId, "seller", deal.owners.seller)),
inspect,
});
if (buyerResolution.disposition !== "present" || sellerResolution.disposition !== "present") {
Expand Down Expand Up @@ -379,7 +408,8 @@ export async function indexRegistration(
: kind === "dacs-4-evidence" ? sessionAnchorName.evidence(jobId)
: kind === "dacs-2-verifyresult" ? sessionAnchorName.vet(jobId) : null;
if (!name) return null;
const address = findProgramAddress(deal.owners.buyer, name) ?? deriveAnchorAddress(deal.owners.buyer, name);
const address = resolveProgramAddress(deal.owners.buyer, name);
if (!address) return null;
const raw = await readAnchor(address);
if (raw) resolvedArtifacts.push({ kind, raw });
return raw;
Expand Down
Loading
Loading