diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..d810e87 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -6,7 +6,10 @@ discovery layer (DACS-1 §6.3.6 catalog API), with a browsable directory UI and Agents do NOT need to register to appear here: the indexer **crawls the chain** (see *Discovery — three channels* below) and picks up current structured listings and -the pinned SDK's legacy artifacts through program-name and content-shape detection. Registration adds a display +the pinned SDK's explicit legacy read profile through program-name and content-shape detection. Current +listings must pass the pinned SDK's normative `isListing()` gate before catalog admission. They are exposed as +authenticated catalog candidates with `transactionReadiness.disposition = unassessed`: buyer-local revocation, +rail-authority, payload-capability and delegated signer-control policy remains the transacting SDK's job. Registration adds a display name and (when owner-signed) the "owner-registered" badge — it is never a gate. Live thesis: a Web2 marketplace *asks you to trust its database*. This directory is a @@ -20,7 +23,7 @@ checks in-browser, while chain inclusion still depends on the disclosed proxy/RP | Surface | Spec | How | |---|---|---| | Catalog API | DACS-1 §6.3.6 | Full normative listing filters plus `q`, profile and identity-tier extensions; canonical current listings, explicitly labelled legacy SDK artifacts, and unauthenticated BB-4-verified `GET /api/dacs/bundles/{jobId}` candidates | -| Seller publication | DACS-1 §6.3.4 | `/register` builds and wallet-signs a current Listing, creates its StorageProgram with the live next-nonce/empty-salt mapping from SDK #70, persists non-secret recovery coordinates before broadcast, and independently verifies native address, owner, program name, tuple, identity, signature and content hash before catalog registration | +| Seller publication | DACS-1 §6.3.4 | `/register` uses the SDK rail resolver over release-authenticated PA-1 definitions, refuses attested-payload methods whose seller-runtime production capability it cannot prove, shows the exact server-built unsigned Listing before signature, creates its StorageProgram with the current SDK name/next-nonce/empty-salt mapping, persists non-secret recovery coordinates before broadcast, obtains a portable BFT-finalized `AnchorReceipt`, and independently verifies native address, owner, program name, tuple, identity, signature and content hash before catalog registration | | Registration | — (catalog-side) | `POST /api/dacs/register` with bounded discovery hints. Nothing in the payload is trusted: listings are read from chain, BundleBindings are independently BB-4 verified, CCI badges are resolved from the on-chain GCR, and every offered bundle is cryptographically verified before it counts | | Identity links | DACS-1 / DACS-2 / CCI | GCR links remain informational; identity tiers elevate only from hash/signature/identifier/method/version/freshness-verified `verifiedBy` evidence under an explicit recipe policy | | Reputation derivation | DACS-5 §10.4–§10.5 | logical bundle-address derivation and bounded BB-4/BB-5/BB-6 resolution, strict two-sided evidence graphs, legacy and v0.3 absolute-fault bundles, seller perspective, ratings, exact-decimal volume, settlement uniqueness, SR-2 windows and deterministic receipts | @@ -175,6 +178,32 @@ and response failures retain bounded retries. `STORAGE_NOT_FOUND` is operational diagnostic evidence only: under the current Demos mapping it is never authoritative DACS-5 absence evidence and cannot satisfy BB-8. +The same status response and the catalog UI expose `listingRejectionDiagnostics` with scope +`listing-admission`. It reports stable public-safe classes for normative shape, +verification-method, signature, identity-presentation, owner/seller binding and +declared-hash failures, including bounded listing ID/version coordinates when they can +be safely recovered and explicit republishing guidance. For example, string-valued deliverable verification methods +are excluded as `VERIFICATION_METHOD_INVALID`; the Directory never aliases them to a +registered structured verification-method variant. + +Before replacing malformed live Listings, run the read-only deployment acceptance check +against the deployed Directory and Demos RPC. The replacement manifest must map all three +known old listing IDs to their new anchors/versions and the independent verifier's supported +payload-method kinds: + +```bash +NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \ +DACS_LISTING_UPGRADE_REPLACEMENTS='[...]' \ +npm run check:listing-upgrade +``` + +It proves the actual old anchor bytes fail with `VERIFICATION_METHOD_INVALID`, their public +diagnostics carry the affected listing coordinates, each replacement passes the complete +SDK reader, the reindexed catalog exposes the exact replacement pin with transaction +readiness still unassessed. It never contacts a gateway and never writes, reindexes, +purchases, or invokes an agent. Gateway demo deployment and smoke testing are owned by the +private gateway release and are not Directory/indexer acceptance criteria. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, @@ -211,8 +240,11 @@ The Next app and the indexer speak to the node over **plain HTTP** (storage read unauthenticated GETs; `gcr_routine` uses hand-rolled timestamp-bound auth headers signed with the SDK's pure ed25519). demosdk is NOT a runtime dependency — its dependency tree (rubic bridge → pancakeswap/cetus/…) has unresolvable optionals in consumer installs and -is bundler-hostile. The SDK's pure barrel does all cryptography, on both server and -client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). +is bundler-hostile. SDK verification names resolve through one compatibility seam because +the current top-level SDK barrel statically re-exports those optional rail modules; replace +that seam when the SDK publishes a browser-safe verification subpath. The same verification +code runs on server and client (browser: @noble-shimmed `node:crypto`, a narrow `node:util` +shim over JSON-owned values, and base64url-patched Buffer). ## Honest limitations (MVP) @@ -224,10 +256,16 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). `DACS_ADMIN_TOKEN` as a Bearer token. Run indexing from cron/CI, not public UI. - **Wallet publication uses three signatures**: the embedded IdentityBundle presentation, the Listing, and the catalog pointer/deal set. Registration remains catalog-side and non-normative. - Before any StorageProgram broadcast, the browser persists the public signed artifact, - exact native/write coordinates and unsigned registration. A reload re-verifies that - same anchor and refreshes only the catalog-pointer signature; it never creates another - listing version or re-sends a chain transaction automatically. + The identity signature is used to build the exact unsigned Listing shown on screen; no + broadcastable transaction is returned until the wallet signs those exact bytes. Before + any StorageProgram broadcast, the browser persists the public signed artifact, exact + native/write coordinates and unsigned registration. After broadcast, registration waits + for canonical transaction inclusion, a BFT-confirmed block, portable finalized + `AnchorReceipt`, and independent native readback. A reload re-verifies that same anchor + and refreshes only the catalog-pointer signature; it never creates another listing version + or re-sends a chain transaction automatically. The browser publisher intentionally omits + attested-payload delivery: publish that shape through the seller runtime's current SDK so + DPA-1 can prove the exact producer capability. - **Scanner depth is bounded** per pass. Increase `DACS_SCAN_MAX_TXS` if a backfill or unusually large interval exceeds the configured cap. - **DACS-2 recipe governance is deployment policy.** `verifiedBy` evidence cannot @@ -245,10 +283,12 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). ## DACS surface / conformance declaration -`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile reading, +`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile catalog reading, §6.3.5 well-known generation/crawling, and §6.3.6 catalog discovery. Current artifacts -use directory-native, current-contract evidence-graph validation; the pinned SDK verifier -is retained only for labelled legacy artifacts. DACS-2 tier derivation fails closed on +pass the pinned SDK's normative shape and component-signature APIs and are explicitly +labelled transaction-readiness-unassessed until a buyer runs the complete SDK reader; current-contract +evidence graphs use directory-native validation, while the SDK bundle verifier is retained +for labelled legacy artifacts. DACS-2 tier derivation fails closed on unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, volume, settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog computations remain advisory and independently reproducible from their refs. diff --git a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts index e94052f..ab22b5e 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts @@ -2,18 +2,30 @@ * POST /api/dacs/build-listing — everything the wallet flow needs to publish * a listing, precomputed server-side: * { claim, serviceId, name, description, rails[], delivery[] } - * → { listing, message, anchorAddress, exists, tx } + * → identity preimage, then the exact unsigned Listing, then (only after its + * wallet signature is verified) a broadcastable anchor transaction. * - * For a new version the client wallet-signs `message`, inserts the signed - * listing into `tx`, and broadcasts it. When the same owner/name already holds + * For a new version the client wallet-signs `message`; the server verifies the + * signature and binds that exact artifact plus its hashes into `tx`. When the same owner/name already holds * a verified immutable version, the response carries `exists:true` and no * transaction so the client can resume registration without another write. * Ownership is intrinsic: the native address uses the seller's next account * nonce plus the live empty salt, and readback re-binds owner/name/content. */ import { NextRequest, NextResponse } from "next/server"; -import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical"; +import { + canonicalize, + contentHash, + listingAddress, + logicalToStorageProgramName, + sha256Hex, +} from "@kynesyslabs/dacs/canonical"; import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; +import { isListing, type ListingDraft } from "@kynesyslabs/dacs/artifacts"; +import { + resolveListingPayloadVerificationCapability, + resolveListingRails, +} from "@/src/sdkListingValidation"; import { deriveStorageAddress, LIVE_STORAGE_SALT, @@ -28,6 +40,7 @@ import { negotiationPhaseForPricing, publishableRail, PUBLISHABLE_PRICING_KINDS, + PUBLISHER_IN_CODE_RAIL_DEFINITIONS, type PublishablePricingKind, } from "@/src/catalog/listingOptions"; @@ -61,6 +74,7 @@ export async function POST(req: NextRequest) { claim?: string; serviceId?: string; name?: string; description?: string; rails?: string[]; delivery?: string[]; category?: string; tags?: string[]; publicEndpoint?: string; identityPresentedAt?: number; identitySignature?: string; + listingSignature?: string; pricing?: { kind?: PublishablePricingKind; amount?: string; currency?: string; unit?: string; minTotal?: string; minPct?: number; maxPct?: number; selectionRule?: "lowest-price" | "highest-price" | "first-acceptable"; @@ -147,7 +161,8 @@ export async function POST(req: NextRequest) { // A current Listing embeds a separately signed IdentityBundle. The first // request returns that preimage; the second includes the wallet signature - // and receives the final listing preimage + anchor transaction. + // and receives the exact Listing preimage. Only a third request carrying the + // Listing signature receives a broadcastable anchor transaction. const serverNow = Date.now(); const identityPresentedAt = body.identitySignature ? Number(body.identityPresentedAt) : serverNow; if (body.identitySignature && ( @@ -203,7 +218,11 @@ export async function POST(req: NextRequest) { ? { kind: "storage-program", accessModel: "public" } : deliverableKind === "entitlement" ? { kind: "entitlement", durationSec: 2_592_000, renewable: false } - : { kind: "attested-payload", payloadFormat: "application/json" }; + : { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }; const auctionDeadline = identityPresentedAt + 7 * 24 * 60 * 60 * 1000; const negotiationKind = negotiationPhaseForPricing(pricingKind); const negotiationStep = negotiationKind === "negotiate-rfq" @@ -238,15 +257,69 @@ export async function POST(req: NextRequest) { terms: {}, validity: { notBefore: identityPresentedAt, ...(pricingKind === "auction" ? { notAfter: auctionDeadline } : {}) }, }; + const railResolution = resolveListingRails({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + payPhases: listing.pipeline + .filter((phase) => phase.kind.startsWith("pay-")) + .map((phase) => ({ + kind: phase.kind, + rail: (phase.parameters as Record | undefined)?.rail, + })), + acceptedRails: listing.acceptedRails, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + }); + if (railResolution.disposition !== "verified") { + return NextResponse.json( + { error: `listing rail authority is ${railResolution.disposition} (${railResolution.reason}); publication refused` }, + { status: 409 }, + ); + } + const payloadCapability = await resolveListingPayloadVerificationCapability( + listing as unknown as ListingDraft, + "produce", + ); + if ( + payloadCapability.disposition !== "not-applicable" && + payloadCapability.disposition !== "supported" + ) { + return NextResponse.json( + { error: "This browser publisher cannot prove the seller runtime's exact attested-payload production capability. Publish this delivery type through the current SDK seller runtime." }, + { status: 409 }, + ); + } const hash = contentHash(listing as Record); const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII + let signedCandidate: Record | undefined; + if (body.listingSignature !== undefined) { + const listingSigHex = body.listingSignature.replace(/^(0x)+/i, ""); + if (!/^[0-9a-fA-F]{128}$/.test(listingSigHex)) { + return NextResponse.json({ error: "listing signature must be a 64-byte Ed25519 signature" }, { status: 400 }); + } + let listingSignatureValid = false; + try { + listingSignatureValid = ed25519Verify( + Buffer.from(message, "utf8"), + Uint8Array.from(Buffer.from(listingSigHex, "hex")), + publicKeyFromRaw(Uint8Array.from(Buffer.from(hex, "hex"))), + ); + } catch { /* malformed signatures fail closed */ } + signedCandidate = { + ...listing, + signature: { algorithm: "ed25519", signer: did, value: listingSigHex }, + }; + if (!listingSignatureValid || !isListing(signedCandidate)) { + return NextResponse.json({ error: "the signed listing failed current SDK validation" }, { status: 400 }); + } + } const logicalAddress = listingAddress(did, serviceId, listingVersion); // StorageProgram names are producer-held write inputs, not public resolution // keys. This deterministic colon-free encoding lets THIS producer recover a // broadcast whose response was lost without pretending consumers can derive // the native address from the logical one. - const programName = `dacs1-${Buffer.from(logicalAddress, "utf8").toString("base64url")}`; + const programName = logicalToStorageProgramName(logicalAddress); const resolution = await resolveOwnedAnchorByName(programName, owner); if (resolution.status === "indeterminate") { return NextResponse.json( @@ -270,16 +343,33 @@ export async function POST(req: NextRequest) { verified.scope.listingVersion !== listingVersion ) { return NextResponse.json( - { error: "the existing owner-bound program does not contain the expected verified listing version" }, + { error: "the existing owner-bound program does not contain the expected authenticated listing candidate" }, { status: 409 }, ); } // Listing versions are immutable. Recover the already-signed artifact and // continue registration; never overwrite it with newly generated bytes. anchorAddress = resolution.address; - publishedListing = verified.listing as Record; + publishedListing = verified.listing as unknown as Record; publishedHash = verified.contentHash; } else { + // Do not prepare a broadcastable transaction until the wallet has signed + // the exact artifact returned by the preview response. + if (!signedCandidate) { + return NextResponse.json({ + listing, + message, + contentHash: hash, + artifactProfile: "dacs-v0.1", + logicalAddress, + programName, + exists: false, + tx: null, + publicationReady: false, + railResolution, + payloadCapability, + }); + } const nonce = await accountNonce(hex); const txNonce = nonce + 1; anchorAddress = deriveStorageAddress(owner, programName, txNonce, LIVE_STORAGE_SALT); @@ -288,8 +378,12 @@ export async function POST(req: NextRequest) { storageAddress: anchorAddress, programName, encoding: "json", - data: "__SIGNED_LISTING__", - metadata: { logicalAddress }, + data: signedCandidate, + metadata: { + logicalAddress, + contentHash: hash, + envelopeHash: sha256Hex(canonicalize(signedCandidate)), + }, acl: { mode: "public" }, salt: LIVE_STORAGE_SALT, storageLocation: "onchain", @@ -310,6 +404,7 @@ export async function POST(req: NextRequest) { status: "", blockNumber: null, }; + publishedListing = signedCandidate; } const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did); @@ -342,6 +437,9 @@ export async function POST(req: NextRequest) { anchorAddress, exists, tx, + publicationReady: true, + railResolution, + payloadCapability, registration: { ...registration, ownerSignature: { diff --git a/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts index cae346e..9bb6f4a 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/confirm-listing/route.ts @@ -2,12 +2,16 @@ * POST /api/dacs/confirm-listing — independently read and verify a listing * after the wallet broadcasts its StorageProgram transaction. * - * Visibility alone is insufficient: the native address, owner, producer-held - * program name, listing tuple, content hash, seller, identity presentation and - * listing signature must all bind before the browser may register the pointer. + * Visibility alone is insufficient: the canonical transaction and its + * BFT-confirmed block first produce a portable finalized AnchorReceipt. The + * native address, owner, producer-held program name, listing tuple, content + * hash, seller, identity presentation and listing signature must then all bind + * before the browser may register the pointer. */ import { NextRequest, NextResponse } from "next/server"; +import { listingAddress } from "@kynesyslabs/dacs/canonical"; import { readAnchorRecord } from "@/src/catalog/chain"; +import { finalizedListingAnchorReceipt } from "@/src/catalog/listingFinality"; import { verifyListing } from "@/src/catalog/listingVerification"; import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security"; @@ -26,6 +30,7 @@ export async function POST(req: NextRequest) { sellerClaim?: unknown; listingId?: unknown; listingVersion?: unknown; + transactionRef?: unknown; } | null; if ( !body || @@ -34,7 +39,10 @@ export async function POST(req: NextRequest) { typeof body.contentHash !== "string" || !/^[0-9a-f]{64}$/.test(body.contentHash) || typeof body.sellerClaim !== "string" || canonicalOwner(body.sellerClaim) === null || typeof body.listingId !== "string" || !/^[a-z0-9-]{1,64}$/.test(body.listingId) || - !Number.isSafeInteger(body.listingVersion) || Number(body.listingVersion) < 1 + !Number.isSafeInteger(body.listingVersion) || Number(body.listingVersion) < 1 || + (body.transactionRef !== undefined && ( + typeof body.transactionRef !== "string" || !/^[0-9a-fA-F]{64}$/.test(body.transactionRef) + )) ) { return NextResponse.json({ error: "invalid listing confirmation coordinates" }, { status: 400 }); } @@ -68,13 +76,42 @@ export async function POST(req: NextRequest) { ); } + const logicalAddress = listingAddress( + body.sellerClaim, + body.listingId, + Number(body.listingVersion), + ); + const finality = await finalizedListingAnchorReceipt({ + anchorAddress: body.anchorAddress, + logicalAddress, + programName: body.programName, + listingContentHash: body.contentHash, + sellerClaim: body.sellerClaim, + transactionRef: typeof body.transactionRef === "string" + ? body.transactionRef.toLowerCase() + : undefined, + }); + if (finality.status === "pending") { + return NextResponse.json( + { confirmed: false, state: "finality-pending", reason: finality.reason }, + { status: 202 }, + ); + } + if (finality.status === "rejected") { + return NextResponse.json( + { confirmed: false, state: "finality-failed", error: finality.reason }, + { status: 409 }, + ); + } + return NextResponse.json({ confirmed: true, - state: "verified", + state: "finalized-and-verified", anchorAddress: body.anchorAddress, contentHash: verified.contentHash, sellerClaim: verified.sellerClaim, listingId: body.listingId, listingVersion: body.listingVersion, + anchorReceipt: finality.receipt, }); } diff --git a/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts b/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts index fca3095..65bd9d9 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/derive/route.ts @@ -32,6 +32,8 @@ export async function GET(req: NextRequest) { found: !!anchored, valid, ownedByClaim: valid, - title: verified?.listing.name ?? null, + title: verified?.profile === "dacs-v0.1" + ? verified.listing.offering.title + : verified?.listing.name ?? null, }); } diff --git a/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts index 6cc6832..46c3d8c 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts @@ -5,11 +5,10 @@ * known deals. Registration becomes "confirm what we found", not data entry. */ import { NextRequest, NextResponse } from "next/server"; -import { isListing } from "@kynesyslabs/dacs/artifacts"; -import { stripSignature } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; import { readAnchor } from "@/src/catalog/chain"; import { gcrGetIdentities } from "@/src/catalog/gcr"; +import { verifyListing } from "@/src/catalog/listingVerification"; import { loadScanState } from "@/src/catalog/store"; export async function GET(req: NextRequest) { @@ -26,9 +25,14 @@ export async function GET(req: NextRequest) { for (const [address, o] of Object.entries(state.listings)) { if (o.toLowerCase() !== owner.toLowerCase()) continue; const raw = await readAnchor(address); - const scope = raw ? stripSignature(raw) : null; - if (scope && isListing(scope)) { - listings.push({ address, title: (scope as { name?: string }).name ?? address }); + const verified = raw ? await verifyListing(raw) : null; + if (verified) { + listings.push({ + address, + title: verified.profile === "dacs-v0.1" + ? verified.listing.offering.title + : verified.listing.name, + }); } } diff --git a/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts b/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts index 3f3efdc..04c59c5 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts @@ -1,6 +1,6 @@ /** * POST /api/dacs/prepare-registration — issue a fresh, content-bound owner - * signing message for an already verified listing pointer. + * signing message for an already finalized and authenticated listing pointer. * * The browser can safely resume registration after a reload or an expired * signature without rebuilding or rebroadcasting the on-chain listing. diff --git a/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts b/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts index 65acf41..53783e7 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts @@ -46,6 +46,7 @@ export async function GET(req: NextRequest) { ...(l.pricing.priceHint ? { priceHint: l.pricing.priceHint } : {}), }, status: l.status, + ...(l.revocation ? { revocation: l.revocation } : {}), }); } diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index bcee314..d06f3e9 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -248,6 +248,7 @@ pre.artifact { /* ── Catalog-freshness chip (home, top right) ─────────────────────────────── */ .h1-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.catalog-status { display: grid; justify-items: end; gap: 8px; max-width: min(100%, 720px); } .sync-chip { display: inline-flex; align-items: center; gap: 7px; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); @@ -260,6 +261,16 @@ pre.artifact { .sync-chip.warn .sync-dot { background: #d97706; } .sync-chip.warn { color: var(--text-primary); } .sync-chip.err .sync-dot { background: var(--red-strong); } +.listing-rejection-notice { + width: min(100%, 720px); padding: 10px 12px; border: 1px solid var(--amber-border, #f3c67a); + border-radius: var(--radius-card); background: var(--amber-soft, #fff8e8); color: var(--text-primary); + font-size: .78rem; +} +.listing-rejection-notice summary { cursor: pointer; font-weight: 700; } +.listing-rejection-notice p { margin: 8px 0; color: var(--text-secondary); } +.listing-rejection-notice ul { display: grid; gap: 8px; margin: 8px 0 0; padding-left: 18px; } +.listing-rejection-notice li span { display: block; color: var(--text-secondary); margin: 2px 0; } +.listing-rejection-notice code { font-size: .68rem; } .sync-dot.pulse { animation: sync-pulse 1.1s ease-in-out infinite; } @keyframes sync-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .sync-refresh { diff --git a/reference-implementations/dacs-directory/app/how-it-works/page.tsx b/reference-implementations/dacs-directory/app/how-it-works/page.tsx index 2f0c7f7..1b56ed7 100644 --- a/reference-implementations/dacs-directory/app/how-it-works/page.tsx +++ b/reference-implementations/dacs-directory/app/how-it-works/page.tsx @@ -12,16 +12,17 @@ const TRUST = [ { title: "Identity signals stay separate", text: "A signed listing proves control of its signing key. GCR links connect that key to accounts or wallets. Only a fresh passing DACS-2 result can elevate the identity to DACS-verified; the directory never treats those three signals as interchangeable.", }, { title: "Deal history is derived, not reviewed", text: "The directory counts a bundle only after strict signature/reference checks, reconciles buyer and seller copies, and applies perspective, fault and neutral-outcome rules. Ratings and transactional volume remain empty until their signed records can be resolved.", }, { title: "The directory is a cache — verify the cryptography", text: "“Verify yourself” checks required party signatures and referenced-artifact signatures/hashes in your browser. The server still ferries RPC bytes, so this proves internal consistency rather than independent chain inclusion; a future Demos proof/CORS-safe read path is needed to remove that final trust boundary.", }, + { title: "Discovery is not permission to pay", text: "The directory authenticates catalog candidates. It does not apply a buyer's revocation, rail-authority, payload-capability, or delegated signer-control policy. A transacting buyer must dereference the exact artifact and obtain a verified result from the current SDK reader.", }, ]; const DISCOVERY = [ - { title: "Registered", text: "Anyone submits an agent's on-chain pointers via the register page. Nothing in the submission is trusted — listings, identity and deals are all verified from chain before appearing." }, + { title: "Registered", text: "Anyone submits an agent's on-chain pointers via the register page. Nothing in the submission is trusted — listing candidates are authenticated from chain, while identity and deal evidence is checked under its own rules before appearing." }, { title: "Discovered on-chain", text: "The indexer walks the chain's transaction history, spots DACS artifacts by their program names, and attributes deals to sellers via the anchored agreements. Agents nobody registered appear automatically." }, { title: "Found through deals", text: "Every verified deal names its counterparty — so the catalog grows along the commerce graph itself." }, ]; const STATUS = [ - { stage: "Identify", standard: "IdentityBundle + signed Listing", sdk: "Compact DID / CCI profile", directory: "Publishes and verifies current Listings; labels legacy SDK artifacts" }, + { stage: "Identify", standard: "IdentityBundle + signed Listing", sdk: "Full buyer-local Listing disposition", directory: "Authenticates catalog candidates; labels transaction readiness unassessed" }, { stage: "Vet", standard: "Fresh recipe-backed verification", sdk: "Optional vet seam", directory: "Shows links separately; never promotes them to DACS-verified" }, { stage: "Negotiate", standard: "Fixed, RFQ, sealed envelope", sdk: "Fixed price integrated", directory: "Publishes structured models and an optional engagement endpoint" }, { stage: "Settle", standard: "Rail + delivery evidence", sdk: "x402 and EVM ERC-20", directory: "Displays signed rail/deliverable terms; does not move funds" }, diff --git a/reference-implementations/dacs-directory/app/llms.txt/route.ts b/reference-implementations/dacs-directory/app/llms.txt/route.ts index c65ea8a..fe22f7b 100644 --- a/reference-implementations/dacs-directory/app/llms.txt/route.ts +++ b/reference-implementations/dacs-directory/app/llms.txt/route.ts @@ -23,9 +23,11 @@ GET ${base}/api/dacs/listings supports category, repeated tag, credential, prima artifactProfile=dacs-v0.1 identifies a current structured Listing. artifactProfile=legacy-sdk-v0.1 identifies the pinned SDK compatibility shape. artifactProfile=fixture-listing identifies a local fixture that is not signed or chain-anchored. Treat a missing profile as legacy for backward compatibility. A publicEndpoint, when present, is an advertised engagement route rather than a trust anchor. reachabilityHint is a time-stamped, non-authoritative catalog probe; treat stale hints as unknown and never use them for validity, identity, revocation, or reputation decisions. +transactionReadiness.disposition=unassessed is deliberate: Directory admission authenticates a catalog candidate but does not execute buyer-local revocation, rail-authority, payload-capability, or delegated signer-control policy. A transacting client must dereference the signed artifact and obtain a verified disposition from the current SDK Listing reader before payment. + ## Publication diagnostics -GET ${base}/api/dacs/status returns bounded, public-safe dead-letter diagnostics. Use locator=stor-... for one exact storage reference and deadLetterLimit=1..100 to bound the recent list. An unclassified-storage result means the scanner could not read enough data to establish that the reference contains a DACS artifact; it is not an attribution of fault to a publisher. +GET ${base}/api/dacs/status returns bounded, public-safe dead-letter and listing-admission diagnostics. Use locator=stor-... for one exact storage reference and deadLetterLimit=1..100 to bound the recent list. Listing rejections include public-safe listing coordinates when recoverable plus republishing guidance in the human UI. An unclassified-storage result means the scanner could not read enough data to establish that the reference contains a DACS artifact; it is not an attribution of fault to a publisher. ## Trust boundaries diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index 477e9d3..d515643 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -20,7 +20,6 @@ import { const WALLET_URL = "https://chromewebstore.google.com/detail/demos-wallet/nefongcpmdahjaijjkihgieiamoahcoo"; const DELIVERY_OPTIONS = [ - { id: "deliver-attested-payload", label: "Verified result", hint: "A result such as data, analysis, or code with an authenticity attestation." }, { id: "deliver-storage-program", label: "On-chain result", hint: "The deliverable is stored on-chain or bound to an external payload by hash." }, { id: "deliver-entitlement", label: "Access or entitlement", hint: "A time-bound API, subscription, quota, or access grant." }, ]; @@ -36,14 +35,51 @@ type BuiltListing = { contentHash: string; logicalAddress: string; programName: string; - anchorAddress: string; + anchorAddress?: string; exists: boolean; + publicationReady?: boolean; tx: Record | null; - registration: Record & { + registration?: Record & { ownerSignature?: { message?: string; signedAt?: number }; }; }; +type PreparedListing = { + input: Record; + identityPresentedAt: number; + identitySignature: string; + publication: BuiltListing; +}; + +const objectValue = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + +function transactionRef(value: unknown): string | null { + const normalize = (candidate: unknown): string | null => { + if (typeof candidate !== "string") return null; + const hex = candidate.replace(/^0x/i, ""); + return /^[0-9a-fA-F]{64}$/.test(hex) ? hex.toLowerCase() : null; + }; + const direct = normalize(value); + if (direct) return direct; + const envelope = objectValue(value); + const data = objectValue(envelope?.data); + for (const candidate of [ + envelope?.hash, + envelope?.txHash, + envelope?.transactionHash, + data?.hash, + data?.txHash, + data?.transactionHash, + ]) { + const parsed = normalize(candidate); + if (parsed) return parsed; + } + return null; +} + export default function Register() { const wallet = useDemosWallet(); const [screen, setScreen] = useState("connect"); @@ -68,6 +104,7 @@ export default function Register() { const [status, setStatus] = useState(null); const [profileUrl, setProfileUrl] = useState(null); const [pendingPublication, setPendingPublication] = useState(null); + const [preparedListing, setPreparedListing] = useState(null); const [recoveryLoaded, setRecoveryLoaded] = useState(false); const operationInFlight = useRef(false); @@ -126,7 +163,7 @@ export default function Register() { const confirmAnchoredListing = async ( pending: PendingListingPublication, onStep?: (step: PublishStep) => void, - ): Promise => { + ): Promise> => { onStep?.("confirming"); setPublishStep("confirming"); setStatus("Waiting for the exact signed listing to become readable and independently verifiable…"); @@ -144,10 +181,13 @@ export default function Register() { sellerClaim: pending.claim, listingId: pending.listingId, listingVersion: pending.listingVersion, + transactionRef: pending.transactionRef, }), }); const body = await response.json(); - if (response.ok && body.confirmed === true) return; + if (response.ok && body.confirmed === true && objectValue(body.anchorReceipt)) { + return body.anchorReceipt as Record; + } if (response.status !== 202) { throw new Error(body.error ?? "The anchored listing failed independent verification."); } @@ -166,7 +206,7 @@ export default function Register() { } onStep?.("registering"); setPublishStep("registering"); - setStatus("One final wallet signature connects this verified listing to the directory."); + setStatus("One final wallet signature connects this finalized, authenticated catalog candidate to the directory."); const prepared = await fetch("/api/dacs/prepare-registration", { method: "POST", headers: { "content-type": "application/json" }, @@ -199,7 +239,7 @@ export default function Register() { clearPendingListingPublication(window.localStorage); setPendingPublication(null); setPublishStep("complete"); - setStatus("Your signed listing is anchored, independently verified, and queued for the next index pass."); + setStatus("Your signed listing has a finalized anchor, is authenticated as a catalog candidate, and is queued for the next index pass. Buyers still run their own SDK transaction-readiness checks."); setProfileUrl(`/seller/${encodeURIComponent(pending.claim)}`); setScreen("done"); }; @@ -208,8 +248,8 @@ export default function Register() { pending: PendingListingPublication, onStep?: (step: PublishStep) => void, ): Promise => { - await confirmAnchoredListing(pending, onStep); - const confirmed = { ...pending, stage: "registering" as const }; + const anchorReceipt = await confirmAnchoredListing(pending, onStep); + const confirmed = { ...pending, anchorReceipt, stage: "registering" as const }; if (!savePending(confirmed)) { throw new Error("The listing verified, but this browser cannot preserve its recovery record; directory registration was not attempted."); } @@ -235,10 +275,9 @@ export default function Register() { } }; - const publish = async () => { + const prepareExactListing = async () => { if (!claim || !validDescription || pendingPublication || operationInFlight.current) return; operationInFlight.current = true; - setScreen("publish"); setStatus(null); setFailedAt(null); let activeStep: PublishStep = "building"; try { @@ -261,7 +300,7 @@ export default function Register() { if (!identityBuild.ok) throw new Error(identityDraft.error); activeStep = "signing"; setPublishStep("signing"); - setStatus("First, bind the seller identity to this listing."); + setStatus("Approve the seller identity presentation. The exact Listing preview is built after this signature."); const identitySignature = await wallet.sign(identityDraft.identityMessage); if (!identitySignature) throw new Error(wallet.error ?? "The identity presentation signature was declined."); const build = await fetch("/api/dacs/build-listing", { @@ -276,26 +315,66 @@ export default function Register() { const built = await build.json(); if (!build.ok) throw new Error(built.error); const publication = built as BuiltListing; - const { ownerSignature: _ownerSignature, ...unsignedRegistration } = publication.registration; - let signedListing = publication.listing; - let transaction = publication.tx; + if (!publication.exists && (!publication.message || publication.publicationReady !== false)) { + throw new Error("The listing builder did not return a safe unsigned preview."); + } + setPreparedListing({ + input: listingInput, + identityPresentedAt: Number(identityDraft.identityPresentedAt), + identitySignature, + publication, + }); + setPublishStep("idle"); + setStatus(publication.exists + ? "An existing immutable version was recovered. Review its exact signed artifact before registration." + : "Exact unsigned artifact ready. Review every field below before requesting the Listing signature."); + } catch (error) { + setFailedAt(activeStep); + setPublishStep("failed"); + setStatus((error as Error).message); + } finally { + operationInFlight.current = false; + } + }; + + const publish = async () => { + if (!claim || !preparedListing || pendingPublication || operationInFlight.current) return; + operationInFlight.current = true; + setScreen("publish"); + setStatus(null); setFailedAt(null); + let activeStep: PublishStep = "signing"; + try { + let publication = preparedListing.publication; if (!publication.exists) { - if (!publication.message || !transaction) throw new Error("The listing builder returned an incomplete create transaction."); - setStatus("Now approve the complete structured listing."); + if (!publication.message) throw new Error("The exact Listing signing message is missing."); + setPublishStep("signing"); + setStatus("Approve the exact unsigned Listing shown on the review screen."); const signature = await wallet.sign(publication.message); if (!signature) throw new Error(wallet.error ?? "The listing signature was declined."); - signedListing = { - ...publication.listing, - signature: { algorithm: "ed25519", signer: claim, value: signature.replace(/^(0x)+/i, "") }, - }; - transaction = structuredClone(transaction); - const content = transaction.content as Record | undefined; - const data = content?.data; - if (!Array.isArray(data) || !data[1] || typeof data[1] !== "object" || Array.isArray(data[1])) { - throw new Error("The listing builder returned an invalid StorageProgram transaction."); - } - (data[1] as Record).data = signedListing; + const finalBuild = await fetch("/api/dacs/build-listing", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...preparedListing.input, + identityPresentedAt: preparedListing.identityPresentedAt, + identitySignature: preparedListing.identitySignature, + listingSignature: signature, + }), + }); + const finalBody = await finalBuild.json(); + if (!finalBuild.ok) throw new Error(finalBody.error); + publication = finalBody as BuiltListing; + if ( + publication.contentHash !== preparedListing.publication.contentHash || + !publication.publicationReady + ) throw new Error("The final publication no longer matches the exact reviewed Listing."); } + if (!publication.anchorAddress || !publication.registration) { + throw new Error("The finalized listing builder returned incomplete recovery coordinates."); + } + const { ownerSignature: _ownerSignature, ...unsignedRegistration } = publication.registration; + const signedListing = publication.listing; + const transaction = publication.tx; const listingVersion = Number(signedListing.listingVersion); if (!Number.isSafeInteger(listingVersion) || listingVersion < 1) { throw new Error("The listing builder returned an invalid listing version."); @@ -319,14 +398,23 @@ export default function Register() { } if (!publication.exists) { + if (!transaction) throw new Error("The finalized listing builder returned no anchor transaction."); activeStep = "anchoring"; setPublishStep("anchoring"); setStatus("Approve the on-chain anchor transaction. Its recovery coordinates are already saved in this browser."); const sent = await wallet.send(transaction); if (!sent) throw new Error(wallet.error ?? "The anchor transaction was not acknowledged; check this same anchor before trying anything else."); - pending = { ...pending, stage: "confirming" }; + const txRef = transactionRef(sent); + pending = { + ...pending, + ...(txRef ? { transactionRef: txRef } : {}), + stage: "confirming", + }; if (!savePending(pending)) { throw new Error("The transaction was sent, but this browser could not update its recovery record. Do not publish again; preserve this page and check the anchor."); } + if (!txRef) { + throw new Error("The wallet acknowledged the write without a transaction hash. The exact anchor is saved; use Check chain and resume so the Directory can recover its canonical transaction without rebroadcasting."); + } } else { setStatus("Recovered the existing immutable listing version; no new chain transaction will be sent."); } @@ -417,7 +505,7 @@ export default function Register() { {pricingKind === "metered" &&

Metered listings use deterministic fixed-price acceptance; buyer and seller co-sign the whole-unit quantity and computed total at agreement commit.

}
Payment rail
{PUBLISHABLE_RAIL_OPTIONS.map((option) => )}
{selectedRail?.availability === "operator_gated" ? "AP2 is operator-gated in v0.1 and requires Stripe provider onboarding; the listing records that rail without claiming it is publicly live." : "The selected rail becomes the signed payment step and accepted rail."}
-
Delivery type
{DELIVERY_OPTIONS.map((option) => )}
+
Delivery type
{DELIVERY_OPTIONS.map((option) => )}
Attested-payload services must publish through their current SDK seller runtime, which can prove the exact production method before payment.

{validDescription ? "Ready for review." : <>Before review: {validationIssues.join(" · ")}.} @@ -430,7 +518,7 @@ export default function Register() {

step 3

Review before signing

-

This is the service card buyers will discover. The technical identifiers below become part of the signed artifact.

+

This is the service card buyers will discover. Approve the identity presentation first, then inspect the exact server-built Listing before signing it.

{category.replaceAll(".", " / ")}will be signed

{name}

{description}

@@ -439,8 +527,8 @@ export default function Register() {

{slug} · {claim}

- Preview the machine-readable listing -
{JSON.stringify({
+            {preparedListing?.publication.exists ? "Exact recovered signed artifact" : preparedListing ? "Exact artifact presented for Listing signature" : "Draft projection — build the exact artifact before signing"}
+            
{JSON.stringify(preparedListing?.publication.listing ?? {
               dacsVersion: "1", listingId: slug, listingVersion: "assigned at publish",
               seller: { identity: "separately signed IdentityBundle", displayName: name.trim(), publicEndpoint: publicEndpoint || undefined },
               offering: { title: name.trim(), description: description.trim(), category, tags: tags.split(",").map((tag) => tag.trim()).filter(Boolean), deliverable: delivery.replace("deliver-", "") },
@@ -454,8 +542,9 @@ export default function Register() {
               ] : [],
             }, null, 2)}
+ {status &&

{status}

}

For a new listing, expect four wallet approvals: seller identity, listing signature, on-chain transaction, and directory registration. The directory never receives your private key.

-
+
{preparedListing ? : }
)} diff --git a/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx b/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx index c093ca3..80768d3 100644 --- a/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx +++ b/reference-implementations/dacs-directory/app/seller/[claim]/page.tsx @@ -51,7 +51,7 @@ export default async function Seller({ params }: { params: Promise<{ claim: stri )}{" "} {!seller.ownerRegistered && !seller.discovered && !hasFixtureListings && ( + title="Submitted without this agent's registration signature. Listing candidates below are authenticated from chain; buyer-local transaction readiness remains unassessed."> unverified submission )}{" "} diff --git a/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx b/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx index 7ff1d04..5a25518 100644 --- a/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx +++ b/reference-implementations/dacs-directory/app/service/[seller]/[listingId]/[version]/page.tsx @@ -88,7 +88,7 @@ export default async function ServicePage({ params }: { params: Promise {!isFixtureListing && !seller.ownerRegistered && seller.discovered && discovered on-chain} {isFixtureListing && not chain anchored} {!isFixtureListing && !seller.ownerRegistered && !seller.discovered && ( - + unverified submission )} @@ -123,7 +123,8 @@ export default async function ServicePage({ params }: { params: Promise

Evidence boundaries

  • {seller.cci.length ? "✓" : "–"}
    Identity links

    {seller.cci.length ? `${seller.cci.length} GCR identity link${seller.cci.length === 1 ? "" : "s"}; no fresh DACS-2 verification resolved` : "No linked identities beyond the signing key"}

  • -
  • {isFixtureListing ? "–" : "✓"}
    Listing

    {isFixtureListing ? "Fixture machine contract and content hash match; no chain anchor claimed" : "Signature and chain anchor verified"}

  • +
  • {isFixtureListing ? "–" : "✓"}
    Catalog candidate

    {isFixtureListing ? "Fixture machine contract and content hash match; no chain anchor claimed" : "Structure, signature, identity presentation, owner binding, and chain anchor authenticated"}

  • +
  • Transaction readiness

    Unassessed here; buyers must run the current SDK reader with their own rail, revocation, payload, and signer-control dependencies

  • {engagementEndpoint ? "•" : "–"}
    Endpoint declaration

    {evidence.endpoint.label}; this says nothing about availability

  • {evidence.reachability.kind === "reachable" ? "✓" : "–"}
    Reachability

    {evidence.reachability.label}

  • {evidence.deals.completed ? "✓" : "–"}
    Two-sided deal evidence

    {evidence.deals.label}. {evidence.deals.explanation}.

  • @@ -137,9 +138,9 @@ export default async function ServicePage({ params }: { params: Promise
    Artifact
    directory-service-profile
    Maturity
    listed
    -
    Limits
    Roster maturity hint only; not reputation evidence and not source truth.
    +
    Limits
    Roster maturity hint only; not buyer/session admission, reputation evidence, or source truth.
    -

    This profile lets a verifier check the listing identity, artifact profile, and limitation flags before any service-specific sample or bundle adapter is trusted.

    +

    This profile lets a verifier check the catalog candidate identity, artifact profile, and limitation flags before the current SDK performs buyer-local transaction admission.

    diff --git a/reference-implementations/dacs-directory/e2e/register.spec.ts b/reference-implementations/dacs-directory/e2e/register.spec.ts index f933048..11d4ebd 100644 --- a/reference-implementations/dacs-directory/e2e/register.spec.ts +++ b/reference-implementations/dacs-directory/e2e/register.spec.ts @@ -26,7 +26,7 @@ test("seller publication survives registration failure and reload without rebroa calls.push(request); if (request.method === "connect") return { success: true, data: { address } }; if (request.method === "sign") return { success: true, data: { signature: "ab".repeat(64) } }; - if (request.method === "sendTransaction") return { success: true, data: { hash: "mock-listing-tx" } }; + if (request.method === "sendTransaction") return { success: true, data: { hash: "cd".repeat(32) } }; throw new Error(`unexpected wallet method ${request.method}`); }, }, @@ -42,22 +42,39 @@ test("seller publication survives registration failure and reload without rebroa let registrationPosts = 0; let confirmationPosts = 0; await context.route("**/api/dacs/build-listing", async (route) => { - const input = route.request().postDataJSON() as { identitySignature?: string }; + const input = route.request().postDataJSON() as { identitySignature?: string; listingSignature?: string }; if (!input.identitySignature) { return json(route, { identityMessage: "identity-message", identityPresentedAt: 1_786_360_000_000 }); } - return json(route, { - listing: { dacsVersion: "1", listingId: "recovery-service", listingVersion: 1 }, + const listing = { + dacsVersion: "1", + listingId: "recovery-service", + listingVersion: 1, + offering: { deliverable: { kind: "storage-program", accessModel: "public" } }, + ...(input.listingSignature ? { signature: { algorithm: "ed25519", signer: claim, value: input.listingSignature } } : {}), + }; + if (!input.listingSignature) return json(route, { + listing, message: "listing-message", contentHash, logicalAddress: `dacs1:did%3Ademos%3Aagent%3A${keyHex}:recovery-service:v1`, programName, + exists: false, + publicationReady: false, + tx: null, + }); + return json(route, { + listing, + contentHash, + logicalAddress: `dacs1:did%3Ademos%3Aagent%3A${keyHex}:recovery-service:v1`, + programName, anchorAddress, exists: false, + publicationReady: true, tx: { content: { type: "storageProgram", - data: ["storageProgram", { operation: "CREATE_STORAGE_PROGRAM", data: "__SIGNED_LISTING__", salt: "" }], + data: ["storageProgram", { operation: "CREATE_STORAGE_PROGRAM", data: listing, salt: "" }], nonce: 8, }, }, @@ -69,7 +86,11 @@ test("seller publication survives registration failure and reload without rebroa }); await context.route("**/api/dacs/confirm-listing", async (route) => { confirmationPosts++; - return json(route, { confirmed: true, state: "verified" }); + return json(route, { + confirmed: true, + state: "finalized-and-verified", + anchorReceipt: { receiptVersion: "1", state: "finalized", transactionRef: { kind: "demos-storage-program", value: "cd".repeat(32) } }, + }); }); await context.route("**/api/dacs/prepare-registration", (route) => json(route, { registration: { @@ -94,7 +115,11 @@ test("seller publication survives registration failure and reload without rebroa await expect(page.getByText("Ready for review.", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Review listing" }).click(); await expect(page.getByText(/expect four wallet approvals/i)).toBeVisible(); - await page.getByRole("button", { name: "Sign and publish" }).click(); + await page.getByRole("button", { name: "Build exact artifact" }).click(); + await expect(page.getByText(/Exact unsigned artifact ready/)).toBeVisible(); + await page.getByText("Exact artifact presented for Listing signature").click(); + await expect(page.getByText(/"accessModel": "public"/)).toBeVisible(); + await page.getByRole("button", { name: "Sign exact artifact and publish" }).click(); await expect(page.getByText("temporary registry write failure", { exact: true })).toBeVisible(); await expect(page.locator(".progress-list li.failed")).toContainText("Register the catalog pointer"); @@ -120,7 +145,7 @@ test("seller publication survives registration failure and reload without rebroa await expect(page.getByText(anchorAddress, { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Check chain and resume" }).click(); - await expect(page.getByText(/anchored, independently verified, and queued/)).toBeVisible(); + await expect(page.getByText(/finalized anchor.*authenticated as a catalog candidate/)).toBeVisible(); await expect.poll(() => page.evaluate(() => ( (window as unknown as { __sellerWalletCalls: Array<{ method: string }> }).__sellerWalletCalls .filter((call) => call.method === "sendTransaction").length diff --git a/reference-implementations/dacs-directory/next.config.mjs b/reference-implementations/dacs-directory/next.config.mjs index 5e12040..817d651 100644 --- a/reference-implementations/dacs-directory/next.config.mjs +++ b/reference-implementations/dacs-directory/next.config.mjs @@ -23,6 +23,11 @@ const nextConfig = { resource.request = new URL("./src/shims/node-crypto.ts", import.meta.url).pathname; }), ); + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:util$/, (resource) => { + resource.request = new URL("./src/shims/node-util.ts", import.meta.url).pathname; + }), + ); config.plugins.push( new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"] }), ); diff --git a/reference-implementations/dacs-directory/package-lock.json b/reference-implementations/dacs-directory/package-lock.json index ffb52af..29968c0 100644 --- a/reference-implementations/dacs-directory/package-lock.json +++ b/reference-implementations/dacs-directory/package-lock.json @@ -1862,27 +1862,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "vendor/dacs-sdk": { - "name": "@kynesyslabs/dacs", - "version": "0.1.0-alpha.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@kynesyslabs/demosdk": "^4.0.12", - "@x402/core": "^2.15.0", - "@x402/evm": "^2.15.0", - "@x402/fetch": "^2.15.0", - "viem": "^2.52.2" - }, - "devDependencies": { - "@types/node": "^20", - "typescript": "^5", - "vitest": "^4.0.18" - }, - "engines": { - "node": ">=20" - } } } } diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..7b3d101 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -12,6 +12,7 @@ "start:railway": "bash scripts/start-railway.sh", "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", + "check:listing-upgrade": "tsx scripts/check-live-listing-upgrade.ts", "test": "tsx --test test/*.test.ts test/*.test.mjs", "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", diff --git a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts new file mode 100644 index 0000000..2fbdcbd --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -0,0 +1,171 @@ +/** + * Read-only deployment acceptance for replacing the three malformed live x402 + * Listings. This never writes, reindexes, purchases, or invokes an agent. + * + * Required: + * NEXT_PUBLIC_DIRECTORY_URL=https://... + * DACS_LISTING_UPGRADE_REPLACEMENTS='[{"listingId":"...","replacementLocator":"stor-...","replacementVersion":4,"supportedPayloadMethods":["self-signed"]}, ...]' + */ +import invalidLiveListings from "../test/fixtures/live-invalid-verification-methods.json"; +import { canonicalize } from "@kynesyslabs/dacs/canonical"; +import { validateListingArtifact } from "../src/sdkListingValidation.js"; +import { verifyListingResult } from "../src/catalog/listingVerification.js"; +import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; + +type Replacement = { + listingId: string; + replacementLocator: string; + replacementVersion: number; + supportedPayloadMethods: string[]; +}; + +const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); + +function httpsOrigin(name: string): string { + const raw = process.env[name]?.trim(); + if (!raw) throw new Error(`${name} is required`); + const url = new URL(raw); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) { + throw new Error(`${name} must be an HTTPS origin`); + } + return url.origin; +} + +function replacements(): Replacement[] { + const raw = process.env.DACS_LISTING_UPGRADE_REPLACEMENTS; + if (!raw) throw new Error("DACS_LISTING_UPGRADE_REPLACEMENTS is required"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || parsed.length !== invalidLiveListings.length) { + throw new Error(`replacement manifest must contain exactly ${invalidLiveListings.length} entries`); + } + const allowedKeys = new Set(["listingId", "replacementLocator", "replacementVersion", "supportedPayloadMethods"]); + for (const item of parsed) { + if ( + !item || typeof item !== "object" || Array.isArray(item) || + Object.keys(item).some((key) => !allowedKeys.has(key)) || + typeof item.listingId !== "string" || + typeof item.replacementLocator !== "string" || !/^stor-[0-9a-f]{40}$/.test(item.replacementLocator) || + !Number.isSafeInteger(item.replacementVersion) || Number(item.replacementVersion) < 1 || + !Array.isArray(item.supportedPayloadMethods) || !item.supportedPayloadMethods.every((kind: unknown) => typeof kind === "string") + ) throw new Error("replacement manifest contains an invalid entry"); + } + const typed = parsed as Replacement[]; + for (const old of invalidLiveListings) { + const replacement = typed.find((candidate) => candidate.listingId === old.listingId); + if (!replacement) { + throw new Error(`replacement manifest is missing ${old.listingId}`); + } + if (replacement.replacementVersion <= old.listingVersion) { + throw new Error(`${old.listingId} replacement version must be newer than v${old.listingVersion}`); + } + } + return typed; +} + +async function json(url: string): Promise> { + const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + const body = await response.json() as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(`${url} returned a non-object`); + return body as Record; +} + +async function anchor(locator: string): Promise> { + const body = await json(`${RPC}/storage-program/${locator}`); + if (body.success !== true || !body.data || typeof body.data !== "object" || Array.isArray(body.data)) { + throw new Error(`${locator} is not a public Listing anchor`); + } + return body.data as Record; +} + +const same = (left: unknown, right: unknown): boolean => { + try { return canonicalize(left) === canonicalize(right); } catch { return false; } +}; + +async function main() { + const directory = httpsOrigin("NEXT_PUBLIC_DIRECTORY_URL"); + const manifest = replacements(); + + for (const old of invalidLiveListings) { + const raw = await anchor(old.locator); + const result = await verifyListingResult(raw); + if (result.ok || result.code !== "VERIFICATION_METHOD_INVALID") { + throw new Error(`${old.listingId}@${old.listingVersion} did not fail as VERIFICATION_METHOD_INVALID`); + } + const diagnostics = await json(`${directory}/api/dacs/status?locator=${encodeURIComponent(old.locator)}&deadLetterLimit=10`); + const indexer = diagnostics.indexer as Record | undefined; + const listingDiagnostics = indexer?.listingRejectionDiagnostics as Record | undefined; + const items = Array.isArray(listingDiagnostics?.items) ? listingDiagnostics.items : []; + if (!items.some((item) => item && typeof item === "object" && !Array.isArray(item) && + item.code === "VERIFICATION_METHOD_INVALID" && item.listingId === old.listingId && item.listingVersion === old.listingVersion)) { + throw new Error(`${old.listingId} is missing its expected public rejection diagnostic`); + } + + const replacement = manifest.find((candidate) => candidate.listingId === old.listingId)!; + const replacementRaw = await anchor(replacement.replacementLocator); + const authenticated = await verifyListingResult(replacementRaw); + if (!authenticated.ok || authenticated.value.profile !== "dacs-v0.1") { + throw new Error(`${replacement.listingId}@${replacement.replacementVersion} failed catalog authentication`); + } + const listing = authenticated.value.listing; + const oldSeller = (raw.seller as Record | undefined)?.identity as Record | undefined; + if ( + listing.listingId !== replacement.listingId || + listing.listingVersion !== replacement.replacementVersion || + listing.seller.identity.presentedBy !== oldSeller?.presentedBy + ) { + throw new Error(`${replacement.listingId} replacement tuple does not match its manifest and prior seller`); + } + + const catalog = await json(`${directory}/api/dacs/listings?primaryClaim=${encodeURIComponent(authenticated.value.sellerClaim)}&limit=100`); + const summaries = Array.isArray(catalog.listings) ? catalog.listings : []; + const summary = summaries.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && + candidate.listingId === replacement.listingId && candidate.version === replacement.replacementVersion) as Record | undefined; + if ( + !summary || + (summary.anchor as Record | undefined)?.locator !== replacement.replacementLocator || + summary.contentHash !== authenticated.value.contentHash || + (summary.transactionReadiness as Record | undefined)?.disposition !== "unassessed" + ) throw new Error(`${replacement.listingId} replacement is not exposed by the reindexed catalog`); + + const validation = await validateListingArtifact(replacementRaw, { + nowMs: () => Date.now(), + verifyListingSignature: ({ signature }) => + same(signature, listing.signature), + revocation: { + surfaces: [{ + kind: "catalog", + status: "active", + catalogObservedAt: Number(summary.catalogObservedAt), + }], + readMarker: async (marker) => anchor(marker.locator), + verifyMarkerSignature: () => false, + }, + verifyIdentityPresentation: ({ bundle }) => + same(bundle, listing.seller.identity), + loadRailResolution: () => ({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + }), + resolvePayloadVerificationCapability: ({ verificationMethod }) => + replacement.supportedPayloadMethods.includes(verificationMethod.kind) + ? { disposition: "supported", reason: "deployment acceptance verifier is configured for this method" } + : { disposition: "unsupported", reason: "method is not configured in this independent verifier" }, + verifySellerControl: ({ bundle, signer }) => + signer === bundle.presentedBy && signer === authenticated.value.signer, + }); + if (validation.disposition !== "verified") { + throw new Error(`${replacement.listingId} complete SDK reader returned ${validation.disposition} (${validation.reason})`); + } + + process.stdout.write(`ok ${old.listingId}@${old.listingVersion} rejected -> v${replacement.replacementVersion} SDK-verified and discoverable\n`); + } + process.stdout.write("ok every replacement is independently SDK-verified and discoverable\n"); +} + +main().catch((error) => { + process.stderr.write(`[listing upgrade acceptance] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index ef9bdeb..ddf5d16 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -2,7 +2,7 @@ # Vendors + builds the dacs-sdk (not yet on npm) and installs the app. set -euo pipefail cd "$(dirname "$0")/.." -SDK_REV="44d8ff2a07df8c951b94619d20b957b4bb5ce140" +SDK_REV="f68fed1c63b56a438830f13e706ea4651d675157" # Railway's GitHub integration can check out this repository, but it does not # pass its credentials through to nested private-repository clones. Supply a @@ -31,10 +31,10 @@ else git_with_sdk_auth clone --filter=blob:none https://github.com/DACS-Agent-commerce/dacs-sdk.git vendor/dacs-sdk fi (cd vendor/dacs-sdk && git_with_sdk_auth fetch --depth 1 origin "$SDK_REV" && git_with_sdk_auth checkout --detach "$SDK_REV") - (cd vendor/dacs-sdk && npm install --no-audit --no-fund && npm run build) + (cd vendor/dacs-sdk && npm ci --no-audit --no-fund && npm run build) fi if [ "${DACS_SKIP_APP_INSTALL:-0}" != "1" ]; then - npm install --no-audit --no-fund + npm ci --no-audit --no-fund fi # Seed the (gitignored, runtime-mutated) registrations file from the example # so a fresh clone has demo data without the file churning in git. diff --git a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts index d528fc0..12aab7a 100644 --- a/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts +++ b/reference-implementations/dacs-directory/src/catalog/bundlePolicy.ts @@ -1,6 +1,8 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import type { AttestationBundle } from "@kynesyslabs/dacs/artifacts"; -import type { BundleVerification } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +import { + isLegacyMvpAttestationBundle, +} from "@kynesyslabs/dacs/artifacts"; +import type { BundleVerification } from "@kynesyslabs/dacs"; import { bundleSignerPolicy, demosSigningIdentity } from "./bundleSignerPolicy.js"; import { verifyListing } from "./listingVerification.js"; @@ -84,11 +86,11 @@ export function bundleMatchesRegisteredAnchor( * they must never be allowed to reassign somebody else's bundle/reputation. */ export function bundleMatchesRegisteredDeal( - bundle: AttestationBundle | undefined, + bundle: BundleVerification["bundle"], deal: RegisteredDeal, catalogSeller: string, ): boolean { - if (!bundle || bundle.jobId !== deal.jobId) return false; + if (!isLegacyMvpAttestationBundle(bundle) || bundle.jobId !== deal.jobId) return false; const buyers = bundle.parties.filter((p) => p.role === "buyer"); const sellers = bundle.parties.filter((p) => p.role === "seller"); return buyers.length === 1 && sellers.length === 1 && @@ -129,7 +131,8 @@ function expectedArtifacts(verification: BundleVerification): ExpectedArtifact[] // The pinned compatibility SDK does not resolve or report amendments/ratings. // A nonempty set must fail closed here instead of receiving a partial "strict" // verdict. The current-profile evidence graph resolves ratingRefs separately. - if (!bundle || bundle.agreementRef.kind !== "dacs-3-agreement" || + if (!isLegacyMvpAttestationBundle(bundle) || + !bundle.agreementRef || bundle.agreementRef.kind !== "dacs-3-agreement" || bundle.settlementEvidence.some((ref) => ref.kind !== "dacs-4-evidence") || bundle.vetRecords.some((ref) => ref.kind !== "dacs-2-verifyresult") || [extended?.amendments, extended?.ratingRefs].some((refs) => refs !== undefined && @@ -285,7 +288,7 @@ export function verifiedListingTerms( } export function bundleCategory( - bundle: AttestationBundle | undefined, + bundle: { listingRef: { listingId: string } } | undefined, categoriesByListing: Map, ): string | undefined { return bundle ? categoriesByListing.get(String(bundle.listingRef.listingId)) : undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index fce0af5..3fde0d2 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -38,6 +38,11 @@ export interface AnchorRecord { data: Record; owner?: string; programName?: string; + storageAddress?: string; + metadata?: Record; + createdByTx?: string; + lastModifiedByTx?: string; + interactionTxs?: string[]; } export async function readAnchorRecord(address: string): Promise { @@ -53,9 +58,23 @@ export async function readAnchorRecord(address: string): Promise; owner?: string; programName?: string; + storageAddress?: string; + metadata?: Record; + createdByTx?: string; + lastModifiedByTx?: string; + interactionTxs?: string[]; }; return json?.success && json.data != null && typeof json.data === "object" && !Array.isArray(json.data) - ? { data: json.data, owner: json.owner, programName: json.programName } + ? { + data: json.data, + owner: json.owner, + programName: json.programName, + storageAddress: json.storageAddress, + metadata: json.metadata, + createdByTx: json.createdByTx, + lastModifiedByTx: json.lastModifiedByTx, + interactionTxs: json.interactionTxs, + } : null; } catch { return null; @@ -158,12 +177,26 @@ export async function resolveOwnedAnchorByName( data?: Record; owner?: string; programName?: string; + storageAddress?: string; + metadata?: Record; + createdByTx?: string; + lastModifiedByTx?: string; + interactionTxs?: string[]; }; if ( body.success && body.data != null && typeof body.data === "object" && !Array.isArray(body.data) && body.programName === programName ) { - record = { data: body.data, owner: body.owner, programName: body.programName }; + record = { + data: body.data, + owner: body.owner, + programName: body.programName, + storageAddress: body.storageAddress, + metadata: body.metadata, + createdByTx: body.createdByTx, + lastModifiedByTx: body.lastModifiedByTx, + interactionTxs: body.interactionTxs, + }; } } catch { /* classified below */ } if (!record) { diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 4b6d057..719c5ec 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -1,4 +1,5 @@ import { publicDemosRpcUrl } from "./substrateDiscovery.js"; +import { LISTING_REJECTION_CODES } from "./listingAdmission.js"; export const artifactProfiles = ["dacs-v0.1", "legacy-sdk-v0.1", "fixture-listing"] as const; @@ -7,7 +8,7 @@ export const listingSummarySchema = { $id: "/schemas/listing-summary.schema.json", title: "DACS Directory ListingSummary", type: "object", - required: ["listingId", "version", "contentHash", "anchor", "seller", "offering", "pricing", "status", "catalogObservedAt"], + required: ["listingId", "version", "contentHash", "anchor", "seller", "offering", "pricing", "status", "catalogObservedAt", "transactionReadiness"], properties: { listingId: { type: "string", minLength: 1 }, version: { type: "integer", minimum: 1 }, @@ -49,7 +50,7 @@ export const listingSummarySchema = { }, }, status: { enum: ["active", "revoked"] }, - revocationBinding: { + revocation: { type: "object", required: [ "sellerPrimaryClaim", "listingId", "listingVersion", "listingContentHash", @@ -70,6 +71,16 @@ export const listingSummarySchema = { }, }, catalogObservedAt: { type: "integer" }, + transactionReadiness: { + type: "object", + required: ["disposition", "reason"], + additionalProperties: false, + properties: { + disposition: { const: "unassessed" }, + reason: { type: "string", minLength: 1 }, + }, + description: "Catalog admission is not buyer/session admission. Run the current SDK Listing reader with buyer-local dependencies before transacting.", + }, reputationHint: { type: "object" }, inspection: { type: "object", @@ -84,8 +95,8 @@ export const listingSummarySchema = { }, allOf: [{ if: { properties: { status: { const: "revoked" } }, required: ["status"] }, - then: { required: ["revocationBinding"] }, - else: { not: { required: ["revocationBinding"] } }, + then: { required: ["revocation"] }, + else: { not: { required: ["revocation"] } }, }], } as const; @@ -112,8 +123,10 @@ export const listingRejectionDiagnosticSchema = { required: ["locator", "code", "message", "occurrences", "firstSeenAt", "lastSeenAt"], properties: { locator: { type: "string", pattern: "^stor-[0-9a-f]{40}$" }, - code: { enum: ["SELLER_CLAIM_BINDING", "OWNER_CLAIM_BINDING"] }, - message: { type: "string", description: "Public-safe explanation of the failed listing binding." }, + listingId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" }, + listingVersion: { type: "integer", minimum: 1 }, + code: { enum: LISTING_REJECTION_CODES }, + message: { type: "string", description: "Public-safe explanation of the failed listing admission." }, occurrences: { type: "integer", minimum: 1 }, firstSeenAt: { type: "integer", minimum: 0 }, lastSeenAt: { type: "integer", minimum: 0 }, @@ -190,7 +203,7 @@ export const catalogStatusSchema = { type: "object", required: ["scope", "total", "byCode", "query", "returned", "hasMore", "items"], properties: { - scope: { const: "listing-registration-binding" }, + scope: { const: "listing-admission" }, total: { type: "integer", minimum: 0 }, byCode: { type: "object", additionalProperties: { type: "integer", minimum: 0 } }, query: { diff --git a/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts b/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts index 8ee0d43..39789f5 100644 --- a/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts +++ b/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts @@ -2,6 +2,7 @@ import { createHash, createPublicKey, verify as verifySignature } from "node:cry import fixtureReceipt from "../../data/counterparty-evidence/microsoft-counterparty.receipt.json"; import type { SellerRecord } from "./types.js"; +import { DIRECTORY_TRANSACTION_READINESS } from "./inspection.js"; type JsonValue = | null @@ -181,6 +182,7 @@ export function counterpartyEvidenceSellerRecord(now = Date.now()): SellerRecord pricing: {}, status: "active", catalogObservedAt: now, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, }], deals: [], reputation: { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index d3995a4..c07e5a1 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -18,14 +18,13 @@ import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; -// verifyBundleCore has no pure subpath export (dacs-sdk#14) — vendor path. -import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; -// 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 { isLegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; +import { verifyBundleCore } from "@kynesyslabs/dacs"; import { deriveAnchorAddress, readAnchor, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; -import { findValidListingRevocation, ownerClaim, verifyListing } from "./listingVerification.js"; +import { findValidListingRevocation, ownerClaim, verifyListingResult } from "./listingVerification.js"; +import { DIRECTORY_TRANSACTION_READINESS } from "./inspection.js"; +import { listingDiagnosticCoordinates } from "./listingAdmission.js"; import { canonicalDemosAgentClaim } from "./claimRef.js"; import { resolveDemosPrimaryClaimKey } from "./primaryClaimKey.js"; import { listingPresentation } from "./listingMetadata.js"; @@ -48,6 +47,7 @@ import { verifyBundleBinding, } from "./bundleBinding.js"; import { safePublicEndpoint } from "./publicEndpoint.js"; +import { legacySessionAnchorName } from "./legacySessionAnchorName.js"; import { deriveIdentityTier, type ResolveRecipe } from "./identityVerification.js"; import { bundleMatchesRegisteredDeal, @@ -90,6 +90,20 @@ export function listingBindingRejection( return null; } +/** DACS-1 reader step 3: only currently effective Listings are discoverable. */ +export function listingIsCurrentlyEffective( + scope: Record, + now: number, +): boolean { + const validity = scope.validity && typeof scope.validity === "object" && !Array.isArray(scope.validity) + ? scope.validity as { notBefore?: unknown; notAfter?: unknown } + : undefined; + if (!validity) return true; // Explicit legacy profile has no validity window. + if (typeof validity.notBefore === "number" && now < validity.notBefore) return false; + if (typeof validity.notAfter === "number" && now > validity.notAfter) return false; + return true; +} + /** * Index one registration into a verified SellerRecord. * @@ -121,7 +135,7 @@ export async function indexRegistration( const explorerFor = (chainType: string, address: string): string | undefined => chainType === "evm" ? `https://etherscan.io/address/${address}` : chainType === "solana" ? `https://solscan.io/account/${address}` : undefined; - cci = record.claims.map((c) => c.kind === "web2" + cci = record.claims.filter((c) => c.kind === "web2" || c.kind === "wallet").map((c) => c.kind === "web2" ? { kind: c.kind, platform: c.platform, handle: c.handle, ref: c.ref, proofUrl: proofFor(c.platform, c.handle), linkUrl: profileFor(c.platform, c.handle) } : { kind: c.kind, platform: c.chainType, handle: c.address, ref: c.ref, @@ -139,8 +153,17 @@ export async function indexRegistration( for (const anchor of reg.listingAnchors) { const anchored = await readAnchorRecord(anchor); if (!anchored) continue; - const verified = await verifyListing(anchored.data); - if (!verified) continue; + const verification = await verifyListingResult(anchored.data); + if (!verification.ok) { + recordListingRejection( + anchor, + reg.primaryClaim, + verification.code, + listingDiagnosticCoordinates(anchored.data), + ); + continue; + } + const verified = verification.value; const { scope } = verified; const bindingRejection = listingBindingRejection( verified.sellerClaim, @@ -148,12 +171,25 @@ export async function indexRegistration( reg.primaryClaim, ); if (bindingRejection) { - recordListingRejection(anchor, reg.primaryClaim, bindingRejection); + recordListingRejection( + anchor, + reg.primaryClaim, + bindingRejection, + listingDiagnosticCoordinates(verified.scope), + ); continue; } - clearListingRejection(anchor, reg.primaryClaim); const declaredHash = reg.listingContentHashes?.[anchor]?.replace(/^sha256-/, "").toLowerCase(); - if (declaredHash && declaredHash !== verified.contentHash) continue; + if (declaredHash && declaredHash !== verified.contentHash) { + recordListingRejection( + anchor, + reg.primaryClaim, + "DECLARED_CONTENT_HASH_MISMATCH", + listingDiagnosticCoordinates(verified.scope), + ); + continue; + } + clearListingRejection(anchor, reg.primaryClaim); const listingId = typeof scope.listingId === "string" ? scope.listingId : typeof scope.serviceId === "string" ? scope.serviceId : ""; if (!listingId) continue; @@ -161,13 +197,12 @@ export async function indexRegistration( const version = typeof rawVersion === "number" && Number.isSafeInteger(rawVersion) && rawVersion > 0 ? rawVersion : 1; - const validity = scope.validity as { notAfter?: unknown } | undefined; - if (typeof validity?.notAfter === "number" && validity.notAfter < now) continue; + if (!listingIsCurrentlyEffective(scope, now)) continue; const storedCandidates = revocations[verified.contentHash]; const revocationAddresses = Array.isArray(storedCandidates) ? storedCandidates : storedCandidates ? [storedCandidates] : []; - const revocationBinding = await findValidListingRevocation( + const revocation = await findValidListingRevocation( revocationAddresses, verified, version, @@ -205,9 +240,10 @@ export async function indexRegistration( ? scope.buyerRequirement as Record : undefined, terms: scope.terms && typeof scope.terms === "object" ? scope.terms as Record : undefined, - status: revocationBinding ? "revoked" : "active", - ...(revocationBinding ? { revocationBinding } : {}), + status: revocation ? "revoked" : "active", + ...(revocation ? { revocation } : {}), catalogObservedAt: now, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, }); listingArtifacts.set(`${listingId}\n${version}\n${verified.contentHash}`, { locator: anchor, raw: anchored.data }); } @@ -369,9 +405,9 @@ export async function indexRegistration( return raw; }, resolveRef: async (kind, jobId) => { - const name = kind === "dacs-3-agreement" ? sessionAnchorName.agreement(jobId) - : kind === "dacs-4-evidence" ? sessionAnchorName.evidence(jobId) - : kind === "dacs-2-verifyresult" ? sessionAnchorName.vet(jobId) : null; + const name = kind === "dacs-3-agreement" ? legacySessionAnchorName.agreement(jobId) + : kind === "dacs-4-evidence" ? legacySessionAnchorName.evidence(jobId) + : kind === "dacs-2-verifyresult" ? legacySessionAnchorName.vet(jobId) : null; if (!name) return null; const address = findProgramAddress(deal.owners.buyer, name) ?? deriveAnchorAddress(deal.owners.buyer, name); const raw = await readAnchor(address); @@ -382,7 +418,9 @@ export async function indexRegistration( (await resolveDemosPrimaryClaimKey(claim, "ed25519"))?.publicKey ?? null, verify, }).catch(() => null); - const bundle = verification?.bundle; + const bundle = verification && isLegacyMvpAttestationBundle(verification.bundle) + ? verification.bundle + : undefined; const signaturesOk = verification ? hasRequiredBundleSignatures( verification, rawBundle, diff --git a/reference-implementations/dacs-directory/src/catalog/inspection.ts b/reference-implementations/dacs-directory/src/catalog/inspection.ts index 384ddb2..3d3ba4c 100644 --- a/reference-implementations/dacs-directory/src/catalog/inspection.ts +++ b/reference-implementations/dacs-directory/src/catalog/inspection.ts @@ -1,9 +1,15 @@ import type { DirectoryInspectionAffordance, DirectoryServiceMaturity, + DirectoryTransactionReadiness, ListingSummary, } from "./types.js"; +export const DIRECTORY_TRANSACTION_READINESS: DirectoryTransactionReadiness = Object.freeze({ + disposition: "unassessed", + reason: "The Directory authenticated this catalog candidate but did not execute buyer-local revocation, rail-authority, payload-capability, or delegated signer-control policy. Run the current SDK Listing reader before starting a session.", +}); + export type DirectoryServiceProfile = { profileKind: "directory-service-profile"; profileVersion: "0.1"; @@ -22,6 +28,7 @@ export type DirectoryServiceProfile = { noLivePaymentClaim: true; reason: string; }; + transactionReadiness: DirectoryTransactionReadiness; service: { title: string; category: string; @@ -88,6 +95,7 @@ export function directoryInspectionAffordance(listing: ListingSummary): Director export function withDirectoryInspectionAffordance(listing: T): T { return { ...listing, + transactionReadiness: listing.transactionReadiness ?? DIRECTORY_TRANSACTION_READINESS, inspection: directoryInspectionAffordance(listing), }; } @@ -109,8 +117,9 @@ export function buildDirectoryServiceProfile(origin: string, listing: ListingSum maturity: "listed", noReputationClaim: true, noLivePaymentClaim: true, - reason: "Directory observed a listing contract; sample receipts, strict bundle history, and live payment evidence require separate verifier adapters.", + reason: "Directory authenticated a catalog candidate; sample receipts, strict bundle history, buyer-local Listing admission, and live payment evidence require separate verifier adapters.", }, + transactionReadiness: listing.transactionReadiness ?? DIRECTORY_TRANSACTION_READINESS, service: { title: listing.offering.title, category: listing.offering.category, diff --git a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts new file mode 100644 index 0000000..9967dd6 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts @@ -0,0 +1,9 @@ +/** + * Historical SDK-MVP program names used only while reading explicitly + * labelled legacy bundles. These strings are not a current normative SDK API. + */ +export const legacySessionAnchorName = { + agreement: (jobId: string): string => `dacs3:agreement:${jobId}`, + evidence: (jobId: string): string => `dacs4:evidence:${jobId}`, + vet: (jobId: string): string => `dacs2:verifyrecord:${jobId}`, +}; diff --git a/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts new file mode 100644 index 0000000..db27a8d --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts @@ -0,0 +1,45 @@ +/** Stable, public-safe listing admission diagnostics. */ +export const LISTING_REJECTION_CODES = [ + "SELLER_CLAIM_BINDING", + "OWNER_CLAIM_BINDING", + "NORMATIVE_LISTING_INVALID", + "VERIFICATION_METHOD_INVALID", + "LISTING_SIGNATURE_INVALID", + "IDENTITY_PRESENTATION_INVALID", + "LEGACY_LISTING_INVALID", + "DECLARED_CONTENT_HASH_MISMATCH", +] as const; + +export type ListingRejectionCode = typeof LISTING_REJECTION_CODES[number]; + +export interface ListingDiagnosticCoordinates { + listingId: string; + listingVersion: number; +} + +/** Recover only bounded, public-safe coordinates from an otherwise rejected artifact. */ +export function listingDiagnosticCoordinates( + raw: Record, +): ListingDiagnosticCoordinates | undefined { + const listingId = typeof raw.listingId === "string" + ? raw.listingId + : typeof raw.serviceId === "string" + ? raw.serviceId + : undefined; + const version = raw.listingVersion ?? raw.version ?? 1; + return listingId && /^[a-z0-9-]{1,64}$/.test(listingId) && + Number.isSafeInteger(version) && Number(version) >= 1 + ? { listingId, listingVersion: Number(version) } + : undefined; +} + +export const LISTING_REJECTION_MESSAGES: Record = { + SELLER_CLAIM_BINDING: "The authenticated listing candidate seller does not match the registration claim.", + OWNER_CLAIM_BINDING: "The listing anchor owner does not match the registration claim.", + NORMATIVE_LISTING_INVALID: "The current listing does not satisfy the pinned SDK's normative Listing validator.", + VERIFICATION_METHOD_INVALID: "The listing deliverable verification method is missing or is not a registered structured variant.", + LISTING_SIGNATURE_INVALID: "The listing signature is malformed, unsupported, unresolved, or cryptographically invalid.", + IDENTITY_PRESENTATION_INVALID: "The listing seller identity presentation could not be authenticated.", + LEGACY_LISTING_INVALID: "The artifact does not satisfy the SDK's explicit legacy Listing read profile.", + DECLARED_CONTENT_HASH_MISMATCH: "The discovery channel's declared listing content hash does not match the verified artifact.", +}; diff --git a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts new file mode 100644 index 0000000..1041f98 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -0,0 +1,205 @@ +import { canonicalize, contentHash, sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { + assertDemosWriteEvidence, + demosSignedTransactionProofHash, + demosWriteEvidenceToAnchorReceipt, + type DemosWriteEvidence, +} from "../sdkDemosWriteEvidence.js"; +import type { AnchorReceipt } from "@kynesyslabs/dacs/artifacts"; + +import { readAnchorRecord } from "./chain.js"; +import { storageWriteCandidate } from "./scan.js"; + +const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); +const PAGE_SIZE = 100; +const MAX_TRANSACTIONS = 5_000; + +const record = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + +const parsedRecord = (value: unknown): Record | null => { + if (typeof value !== "string") return record(value); + try { return record(JSON.parse(value)); } catch { return null; } +}; + +async function nodeCall(message: string, data: Record): Promise { + const response = await fetch(`${RPC}/`, { + method: "POST", + headers: { "content-type": "application/json" }, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + body: JSON.stringify({ + method: "nodeCall", + params: [{ type: "nodeCall", message, sender: null, receiver: null, timestamp: null, data, extra: "" }], + }), + }); + if (!response.ok) throw new Error(`${message} transport failed`); + const body = await response.json() as { result?: number; response?: unknown }; + if (body.result !== 200) throw new Error(`${message} returned ${String(body.result)}`); + return body.response; +} + +async function chainIdentity(): Promise { + const genesis = record(await nodeCall("getBlockByNumber", { blockNumber: 0 })); + const genesisHash = typeof genesis?.hash === "string" ? genesis.hash.trim().toLowerCase() : ""; + if (genesisHash && (genesis?.number === undefined || genesis.number === 0)) return genesisHash; + const first = record(await nodeCall("getBlockByNumber", { blockNumber: 1 })); + const firstContent = parsedRecord(first?.content); + const previousHash = typeof firstContent?.previousHash === "string" + ? firstContent.previousHash.trim().toLowerCase() + : ""; + if (!first || first.status !== "confirmed" || first.number !== 1 || !previousHash) { + throw new Error("Demos genesis block has no stable chain identity"); + } + return previousHash; +} + +async function findCanonicalWrite( + anchorAddress: string, + listingContentHash: string, + expectedTxRef?: string, +): Promise | null> { + let cursor: number | "latest" = "latest"; + let scanned = 0; + while (scanned < MAX_TRANSACTIONS) { + const page = await nodeCall("getTransactions", { + start: cursor, + limit: Math.min(PAGE_SIZE, MAX_TRANSACTIONS - scanned), + }); + if (!Array.isArray(page) || page.length === 0) return null; + for (const raw of page) { + const tx = record(raw); + const candidate = storageWriteCandidate(tx); + if ( + tx && candidate?.locator === anchorAddress && + candidate.contentHash === listingContentHash && + (!expectedTxRef || candidate.transactionHash === expectedTxRef.toLowerCase()) + ) { + return { ...tx, content: parsedRecord(tx.content) ?? tx.content }; + } + } + scanned += page.length; + const ids = page.map((item) => record(item)?.id) + .filter((id): id is number => Number.isSafeInteger(id) && Number(id) >= 0); + if (ids.length === 0) throw new Error("transaction history page has no valid cursor"); + const lowest = Math.min(...ids); + if (lowest <= 1) return null; + cursor = lowest - 1; + } + throw new Error(`finality lookup exceeded ${MAX_TRANSACTIONS} transactions`); +} + +export type FinalizedListingAnchorResult = + | { status: "pending"; reason: string } + | { status: "rejected"; reason: string } + | { status: "finalized"; receipt: AnchorReceipt }; + +/** + * Establish a portable finalized Demos AnchorReceipt from canonical transaction, + * BFT-confirmed block, and exact native readback. Read visibility alone never + * returns `finalized`. + */ +export async function finalizedListingAnchorReceipt(input: { + anchorAddress: string; + logicalAddress: string; + programName: string; + listingContentHash: string; + sellerClaim: string; + transactionRef?: string; +}): Promise { + try { + const anchored = await readAnchorRecord(input.anchorAddress); + if (!anchored) return { status: "pending", reason: "native anchor is not readable" }; + if ( + anchored.programName !== input.programName || + anchored.storageAddress !== undefined && anchored.storageAddress !== input.anchorAddress + ) return { status: "rejected", reason: "native anchor coordinates do not match" }; + const tx = await findCanonicalWrite( + input.anchorAddress, + input.listingContentHash, + input.transactionRef, + ); + if (!tx) return { status: "pending", reason: "canonical transaction is not finalized" }; + const txRef = typeof tx.hash === "string" ? tx.hash.toLowerCase() : ""; + const blockNumber = Number(tx.blockNumber); + const txContent = record(tx.content); + if (!/^[0-9a-f]{64}$/.test(txRef) || !Number.isSafeInteger(blockNumber) || blockNumber < 0 || !txContent) { + return { status: "rejected", reason: "canonical transaction is malformed" }; + } + const block = record(await nodeCall("getBlockByNumber", { blockNumber })); + const blockContent = parsedRecord(block?.content); + const orderedTransactions = Array.isArray(blockContent?.ordered_transactions) + ? blockContent.ordered_transactions + : []; + const rawTimestamp = blockContent?.timestamp; + const blockTimestamp = Number.isSafeInteger(rawTimestamp) && Number(rawTimestamp) >= 0 + ? Number(rawTimestamp) < 100_000_000_000 ? Number(rawTimestamp) * 1_000 : Number(rawTimestamp) + : NaN; + if ( + !block || block.status !== "confirmed" || block.number !== blockNumber || + typeof block.hash !== "string" || !block.hash || + !Number.isSafeInteger(blockTimestamp) || !orderedTransactions.includes(txRef) || + block.validation_data === undefined + ) return { status: "pending", reason: "canonical block is not BFT-confirmed" }; + + const payload = Array.isArray(txContent.data) ? record(txContent.data[1]) : null; + const transactionMetadata = record(payload?.metadata); + const metadata = anchored.metadata; + const owner = typeof anchored.owner === "string" ? anchored.owner : ""; + const nonce = Number(txContent.nonce); + const nativeValueHash = sha256Hex(canonicalize(anchored.data)); + const finalityProof = canonicalize(block.validation_data); + const evidence: DemosWriteEvidence = { + evidenceVersion: "1", + chainIdentity: await chainIdentity(), + writer: owner, + logicalName: input.logicalAddress, + nativeAddress: input.anchorAddress, + operation: payload?.operation === "WRITE_STORAGE" ? "update" : "create", + nonce, + transactionRef: txRef, + signedTransaction: canonicalize(tx), + signedTransactionHash: demosSignedTransactionProofHash(tx), + blockNumber, + blockHash: block.hash, + blockTimestamp, + finalityProof, + finalityProofHash: sha256Hex(finalityProof), + nativeRead: { + owner, + programName: input.programName, + valueHash: nativeValueHash, + ...(metadata ? { metadataHash: sha256Hex(canonicalize(metadata)) } : {}), + observedAt: Date.now(), + }, + }; + if ( + contentHash(anchored.data) !== input.listingContentHash || + !metadata || !transactionMetadata || !sameCanonical(metadata, transactionMetadata) || + metadata?.logicalAddress !== input.logicalAddress || + metadata.contentHash !== input.listingContentHash || + metadata.envelopeHash !== nativeValueHash + ) return { status: "rejected", reason: "native listing metadata does not bind the signed artifact" }; + assertDemosWriteEvidence(evidence); + return { + status: "finalized", + receipt: demosWriteEvidenceToAnchorReceipt({ + logicalAddress: input.logicalAddress, + contentHash: input.listingContentHash, + writer: input.sellerClaim, + evidence, + }), + }; + } catch (error) { + return { + status: "pending", + reason: error instanceof Error ? error.message : "finality observation failed", + }; + } +} + +function sameCanonical(left: Record, right: Record): boolean { + try { return canonicalize(left) === canonicalize(right); } catch { return false; } +} diff --git a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts index 1d66fb0..24c060d 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts @@ -1,14 +1,24 @@ export const PUBLISHABLE_RAIL_OPTIONS = [ - { railId: "pay-dem", phaseKind: "pay-dem", label: "DEM on Demos", availability: "live" }, - { railId: "pay-x402", phaseKind: "pay-x402", label: "USDC via x402", availability: "live" }, + { railId: "pay-dem", railVersion: 1, phaseKind: "pay-dem", label: "DEM on Demos", availability: "live" }, + { railId: "pay-x402", railVersion: 1, phaseKind: "pay-x402", label: "USDC via x402", availability: "live" }, { railId: "ap2:stripe-paymentintents", + railVersion: 1, phaseKind: "pay-ap2", label: "Stripe PaymentIntents via AP2", availability: "operator_gated", }, ] as const; +/** DACS-4 PA-1 definitions authenticated by this exact reviewed app release. */ +export const PUBLISHER_IN_CODE_RAIL_DEFINITIONS = PUBLISHABLE_RAIL_OPTIONS.map((rail) => ({ + railId: rail.railId, + railVersion: rail.railVersion, + phaseHandler: rail.phaseKind, + governanceAnchoring: "in-code", + signatureValid: true, +})); + export const PUBLISHABLE_PRICING_KINDS = ["fixed", "negotiable", "auction", "metered"] as const; export type PublishablePricingKind = (typeof PUBLISHABLE_PRICING_KINDS)[number]; diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index dd56f1d..2ec4cc0 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,12 +1,21 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import { isListing, type Listing } from "@kynesyslabs/dacs/artifacts"; -import { safePublicEndpoint } from "./publicEndpoint.js"; +import { + ARTIFACT_SEPARATORS, + isListing, + isVerificationMethod, + readListingArtifact, + verifyComponentSignature, + type LegacyMvpListing, + type Listing, +} from "@kynesyslabs/dacs/artifacts"; +import { canonicalDemosAgentClaim } from "./claimRef.js"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, resolveDemosPrimaryClaimKey, sameResolvedPrimaryClaim, verifyPrimaryClaimSignature, + verifyResolvedPrimaryClaimSignature, type ResolvedPrimaryClaimKey, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; @@ -25,100 +34,81 @@ function decodeSignature(value: string): Uint8Array | null { } } -export interface VerifiedListing { - listing: Listing | Record; +interface VerifiedListingBase { scope: Record; contentHash: string; signer: string; sellerClaim: string; - profile: "dacs-v0.1" | "legacy-sdk-v0.1"; } +export type VerifiedListing = + | VerifiedListingBase & { listing: Listing; profile: "dacs-v0.1" } + | VerifiedListingBase & { listing: LegacyMvpListing; profile: "legacy-sdk-v0.1" }; + +export type ListingVerificationFailureCode = + | "NORMATIVE_LISTING_INVALID" + | "VERIFICATION_METHOD_INVALID" + | "LISTING_SIGNATURE_INVALID" + | "IDENTITY_PRESENTATION_INVALID" + | "LEGACY_LISTING_INVALID"; + +export type ListingVerificationResult = + | { ok: true; value: VerifiedListing } + | { ok: false; code: ListingVerificationFailureCode }; + const record = (value: unknown): Record | null => value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; -const PHASES = new Set([ - "vet-credentials", "negotiate-fixed-price", "negotiate-rfq", "negotiate-sealed-envelope", "commit-agreement", "commit-payee-bound-agreement", - "pay-evm-erc20", "pay-solana-spl", "pay-cross-chain-htlc", "pay-cross-chain-liquidity-tank", "pay-ap2", "pay-x402", "pay-dem", - "deliver-storage-program", "deliver-entitlement", "deliver-attested-payload", "rate", -]); -// NOTE (open-world listing shape): the directory does NOT reject a listing that carries -// unknown top-level fields. §11.1.2 (additivity) lets a later minor add top-level listing -// fields, and the older-reads-newer contract (CORE.md) requires an older reader to consume a -// newer-minor artifact correctly; SIG-5 (preserve-unknown) is the mechanism that makes that -// safe — a verifier MAY ignore the *meaning* of unknown fields but MUST preserve them (it must -// not strip them from the signed content). This validator only reads the recognised fields -// below, so unknown fields are neither indexed nor interpreted, and verifyListing() hashes the -// whole scope (contentHash) so they remain signature-bound — a field injected after signing -// still fails the hash. A closed top-level -// allowlist here would reject every listing produced under the first minor that adds a -// top-level field, breaking forward compatibility. -function validPriceTerm(value: unknown): boolean { - const term = record(value); - return Boolean(term && typeof term.amount === "string" && /^(?:0|[1-9]\d*)(?:\.\d*[1-9])?$/.test(term.amount) && - Number.isFinite(Number(term.amount)) && Number(term.amount) > 0 && typeof term.currency === "string" && term.currency.length > 0 && term.currency.length <= 64 && - (term.unit === undefined || (typeof term.unit === "string" && term.unit.length > 0 && term.unit.length <= 64))); +function verificationMethodFailure(raw: Record): boolean { + const offering = record(raw.offering); + const deliverable = record(offering?.deliverable); + const method = deliverable?.verificationMethod; + if (method !== undefined && !isVerificationMethod(method)) return true; + const requiresMethod = Array.isArray(raw.pipeline) && raw.pipeline.some((step) => + record(step)?.kind === "deliver-attested-payload"); + return requiresMethod && ( + deliverable?.kind !== "attested-payload" || !isVerificationMethod(method) + ); } -function currentListing(scope: Record): { - signer: string; sellerClaim: string; signature: Record; -} | null { - const seller = record(scope.seller); +/** + * DACS-1 CF-2 makes only the leading `did` scheme token case-insensitive on + * read. The current SDK validator still requires a lowercase claim scheme, so + * validate a scheme-canonical projection while preserving and verifying the + * exact received bytes. Method text and key casing are never rewritten. + */ +function listingValidationView(raw: Record): Record { + const view = structuredClone(raw); + const normalize = (value: unknown): unknown => typeof value === "string" + ? canonicalDemosAgentClaim(value) ?? value + : value; + const seller = record(view.seller); const identity = record(seller?.identity); - const offering = record(scope.offering); - const pricing = record(scope.pricing); - const validity = record(scope.validity); - const signature = record((scope as Record).signature); - const claims = Array.isArray(identity?.claims) ? identity.claims.map(record).filter(Boolean) as Record[] : []; - const pipeline = Array.isArray(scope.pipeline) ? scope.pipeline.map(record).filter(Boolean) as Record[] : []; - const tags = Array.isArray(offering?.tags) ? offering.tags : []; - const pricingOk = pricing?.kind === "fixed" ? validPriceTerm(pricing.price) - : pricing?.kind === "negotiable" ? validPriceTerm(pricing.bandCenter) && typeof pricing.minPct === "number" && pricing.minPct >= 0 && pricing.minPct < 100 && typeof pricing.maxPct === "number" && pricing.maxPct >= 0 - : pricing?.kind === "auction" ? (!pricing.reservePrice || validPriceTerm(pricing.reservePrice)) && typeof pricing.selectionRule === "string" - : pricing?.kind === "metered" ? validPriceTerm(pricing.unitPrice) && - typeof pricing.unit === "string" && pricing.unit.length > 0 && pricing.unit.length <= 64 && - (pricing.minTotal === undefined || ( - validPriceTerm(pricing.minTotal) && - record(pricing.minTotal)?.currency === record(pricing.unitPrice)?.currency - )) : false; - const hasPayPhase = pipeline.some((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")); - const rails = Array.isArray(scope.acceptedRails) ? scope.acceptedRails.map(record).filter(Boolean) : []; - const railIds = new Set(rails.map((rail) => rail?.railId).filter((rail): rail is string => typeof rail === "string")); - const payBindingsOk = pipeline.filter((step) => typeof step.kind === "string" && step.kind.startsWith("pay-")) - .every((step) => { const parameters = record(step.parameters); return typeof parameters?.rail === "string" && railIds.has(parameters.rail); }); - const negotiationKinds = pipeline.map((step) => step.kind).filter((kind) => typeof kind === "string" && kind.startsWith("negotiate-")); - const negotiationIndex = pipeline.findIndex((step) => typeof step.kind === "string" && step.kind.startsWith("negotiate-")); - const commitmentIndexes = pipeline.flatMap((step, index) => - step.kind === "commit-agreement" || step.kind === "commit-payee-bound-agreement" ? [index] : []); - const commitmentOk = commitmentIndexes.length === 1 && commitmentIndexes[0] === negotiationIndex + 1; - const expectedNegotiation = pricing?.kind === "fixed" ? "negotiate-fixed-price" - : pricing?.kind === "negotiable" ? "negotiate-rfq" : pricing?.kind === "auction" ? "negotiate-sealed-envelope" : ""; - const negotiationOk = pricing?.kind === "metered" - ? negotiationKinds.length === 1 && ( - negotiationKinds[0] === "negotiate-fixed-price" || negotiationKinds[0] === "negotiate-rfq" - ) - : negotiationKinds.length === 1 && negotiationKinds[0] === expectedNegotiation; - const signer = typeof signature?.signer === "string" ? signature.signer : ""; - const sellerClaim = typeof identity?.presentedBy === "string" ? identity.presentedBy : ""; - const signerIdentity = canonicalSigningIdentity(signer); - const sellerIdentity = canonicalSigningIdentity(sellerClaim); - if ( - scope.dacsVersion !== "1" || !Number.isSafeInteger(scope.listingVersion) || Number(scope.listingVersion) < 1 || - typeof scope.listingId !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(scope.listingId) || - typeof seller?.displayName !== "string" || seller.displayName.length > 200 || - (seller.publicEndpoint !== undefined && !safePublicEndpoint(seller.publicEndpoint)) || !sellerClaim || claims.length === 0 || - !claims.some((claim) => typeof claim.ref === "string" && - canonicalSigningIdentity(claim.ref) === sellerIdentity) || - !claims.some((claim) => typeof claim.ref === "string" && - canonicalSigningIdentity(claim.ref) === signerIdentity) || - typeof offering?.title !== "string" || offering.title.length > 200 || typeof offering.description !== "string" || offering.description.length > 2000 || - typeof offering.category !== "string" || !/^[a-z0-9.-]{1,64}$/.test(offering.category) || tags.length > 16 || tags.some((tag) => typeof tag !== "string" || tag.length > 32) || !record(offering.deliverable) || - !record(scope.buyerRequirement) || pipeline.length === 0 || pipeline.some((step) => typeof step.kind !== "string" || !PHASES.has(step.kind)) || - !pricingOk || !negotiationOk || !commitmentOk || - (hasPayPhase && (rails.length === 0 || !payBindingsOk)) || !record(scope.terms) || typeof validity?.notBefore !== "number" || - (typeof validity.notAfter === "number" && validity.notAfter < validity.notBefore) || !signature - ) return null; - return { signer, sellerClaim, signature }; + if (identity) { + identity.presentedBy = normalize(identity.presentedBy); + if (Array.isArray(identity.claims)) { + identity.claims = identity.claims.map((claim) => { + const item = record(claim); + return item ? { ...item, ref: normalize(item.ref) } : claim; + }); + } + const presentation = record(identity.presentation); + if (presentation && Array.isArray(presentation.signatures)) { + presentation.signatures = presentation.signatures.map((signature) => { + const item = record(signature); + return item ? { ...item, ref: normalize(item.ref) } : signature; + }); + } + } + const signature = record(view.signature); + if (signature) signature.signer = normalize(signature.signer); + return view; +} + +function invalid( + code: ListingVerificationFailureCode, +): ListingVerificationResult { + return { ok: false, code }; } async function verifyEd25519( @@ -150,41 +140,94 @@ async function verifyIdentityPresentation( return verifyEd25519(message, sellerClaim, signature.signature, resolveKey); } -/** Verify either the current normative Listing or the pinned SDK compatibility profile. */ -export async function verifyListing( +/** + * Verify either a current normative Listing or the SDK's explicit legacy-MVP + * read profile. Current artifacts never fall back to legacy interpretation. + */ +export async function verifyListingResult( raw: Record, resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, -): Promise { - if (raw.signatures !== undefined) return null; - const current = currentListing(raw); - if (current) { - const scope = { ...raw }; - delete scope.signature; - if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) return null; - if (current.signature.algorithm !== "ed25519" || typeof current.signature.value !== "string") return null; - const hash = contentHash(scope); - const verifiedSigner = await verifyEd25519( - Buffer.from(SEPARATOR + hash, "utf8"), current.signer, current.signature.value, resolveKey, +): Promise { + if (raw.signatures !== undefined) return invalid("LISTING_SIGNATURE_INVALID"); + if (raw.dacsVersion === "1") { + let validationView: Record; + try { + validationView = listingValidationView(raw); + } catch { + return invalid("NORMATIVE_LISTING_INVALID"); + } + if (!isListing(validationView)) { + return invalid(verificationMethodFailure(raw) + ? "VERIFICATION_METHOD_INVALID" + : "NORMATIVE_LISTING_INVALID"); + } + const listing = raw as unknown as Listing; + const signatureVerdict = await verifyComponentSignature( + raw, + ARTIFACT_SEPARATORS.Listing, + { + isSignerAuthorized: (_artifact, signature) => + listing.seller.identity.claims.some((claim) => + canonicalSigningIdentity(claim.ref) === canonicalSigningIdentity(signature.signer)), + resolvePublicKey: async (signature) => { + const resolved = await resolvePrimaryClaimKey( + signature.signer, + signature.algorithm, + resolveKey, + ); + return resolved; + }, + verify: ({ signedBytes, signature, publicKey }) => { + const decoded = decodeSignature(signature.value); + return Boolean( + decoded && verifyResolvedPrimaryClaimSignature( + signedBytes, + decoded, + publicKey, + ), + ); + }, + }, + ); + if (signatureVerdict.status !== "valid") { + return invalid("LISTING_SIGNATURE_INVALID"); + } + const verifiedSigner = await resolvePrimaryClaimKey( + signatureVerdict.signature.signer, + signatureVerdict.signature.algorithm, + resolveKey, ); - const seller = record(raw.seller); - const identity = record(seller?.identity); - const verifiedSeller = identity - ? await verifyIdentityPresentation(identity, current.sellerClaim, resolveKey) - : null; - if (!verifiedSigner || !verifiedSeller) return null; + if (!verifiedSigner) return invalid("LISTING_SIGNATURE_INVALID"); + const identity = listing.seller.identity as unknown as Record; + const verifiedSeller = await verifyIdentityPresentation( + identity, + listing.seller.identity.presentedBy, + resolveKey, + ); + if (!verifiedSeller) return invalid("IDENTITY_PRESENTATION_INVALID"); + const scope = stripSignature(raw) as Record; return { - listing: raw, - scope, - contentHash: hash, - signer: verifiedSigner.canonicalClaim, - sellerClaim: verifiedSeller.canonicalClaim, - profile: "dacs-v0.1", + ok: true, + value: { + listing, + scope, + contentHash: contentHash(scope), + signer: verifiedSigner.canonicalClaim, + sellerClaim: verifiedSeller.canonicalClaim, + profile: "dacs-v0.1", + }, }; } + if (Buffer.byteLength(JSON.stringify(raw), "utf8") > 16_384) { + return invalid("LEGACY_LISTING_INVALID"); + } + const readable = readListingArtifact(raw); + if (!readable || readable.compatibility !== "legacy-mvp") { + return invalid("LEGACY_LISTING_INVALID"); + } const scope = stripSignature(raw); - if (!isListing(scope)) return null; - const listing = scope as unknown as Listing; + const listing = readable.listing; const signature = raw.signature; // Early SDK listings stored only the Ed25519 value. Their signer is still // unambiguous because agentId is inside the signed scope and is also checked @@ -198,26 +241,39 @@ export async function verifyListing( s.algorithm !== "ed25519" || typeof s.signer !== "string" || typeof s.value !== "string" - ) return null; + ) return invalid("LISTING_SIGNATURE_INVALID"); const sig = decodeSignature(s.value); - if (!sig) return null; + if (!sig) return invalid("LISTING_SIGNATURE_INVALID"); const hash = contentHash(scope); const message = Buffer.from(SEPARATOR + hash, "utf8"); const verifiedSigner = await verifyPrimaryClaimSignature( message, sig, s.signer, s.algorithm, resolveKey, ); const verifiedAgent = await resolvePrimaryClaimKey(listing.agentId, s.algorithm, resolveKey); - if (!verifiedSigner || !verifiedAgent || !sameResolvedPrimaryClaim(verifiedSigner, verifiedAgent)) return null; + if (!verifiedSigner || !verifiedAgent || !sameResolvedPrimaryClaim(verifiedSigner, verifiedAgent)) { + return invalid("LISTING_SIGNATURE_INVALID"); + } return { - listing, - scope, - contentHash: hash, - signer: verifiedSigner.canonicalClaim, - sellerClaim: verifiedSigner.canonicalClaim, - profile: "legacy-sdk-v0.1", + ok: true, + value: { + listing, + scope, + contentHash: hash, + signer: verifiedSigner.canonicalClaim, + sellerClaim: verifiedSigner.canonicalClaim, + profile: "legacy-sdk-v0.1", + }, }; } +export async function verifyListing( + raw: Record, + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +): Promise { + const result = await verifyListingResult(raw, resolveKey); + return result.ok ? result.value : null; +} + /** A bogus candidate must never shadow another valid owner-signed marker. */ export async function hasValidListingRevocation( candidateRefs: string[], @@ -291,7 +347,7 @@ export async function verifyListingRevocation( ): Promise { const scope = stripSignature(raw); if ( - scope.listingId !== (listing.scope.listingId ?? (listing.listing as Listing).serviceId) || + scope.listingId !== (listing.scope.listingId ?? listing.scope.serviceId) || scope.listingVersion !== expectedVersion || typeof scope.listingContentHash !== "string" || scope.listingContentHash.toLowerCase() !== listing.contentHash || diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 1e7862b..d94bee4 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -121,7 +121,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise { + const listingRejectionColumns = new Set( + (db.prepare("PRAGMA table_info(listing_rejections)").all() as Array<{ name: string }>).map((column) => column.name), + ); + if (!listingRejectionColumns.has("listing_id")) { + db.exec("ALTER TABLE listing_rejections ADD COLUMN listing_id TEXT"); + } + if (!listingRejectionColumns.has("listing_version")) { + db.exec("ALTER TABLE listing_rejections ADD COLUMN listing_version INTEGER"); + } +}).immediate(); + const readLegacy = (path: string, fallback: T): T => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as T : fallback; @@ -136,7 +161,9 @@ const setJson = db.transaction((key: string, value: unknown) => { .run(key, JSON.stringify(value), Date.now()); }); -// One-time, atomic migration. Legacy files remain as rollback snapshots. +// One-time, atomic migrations. Legacy files remain as rollback snapshots. +// Every read-then-write migration starts IMMEDIATE so concurrent fresh +// processes queue before observing state instead of racing a DEFERRED upgrade. if (!(db.prepare("SELECT 1 FROM kv_state WHERE key='schema-version'").get())) db.transaction(() => { setJson("catalog", readLegacy(LEGACY.catalog, { catalogVersion: "1", generatedAt: 0, sellers: [] })); setJson("registrations", readLegacy(LEGACY.registrations, [])); @@ -144,7 +171,7 @@ if (!(db.prepare("SELECT 1 FROM kv_state WHERE key='schema-version'").get())) db setJson("domains", readLegacy(LEGACY.domains, [])); setJson("fixtures", readLegacy(LEGACY.fixtures, [])); setJson("schema-version", 1); -})(); +}).immediate(); // Every persisted anchor time written before this migration came from the // transaction's producer-controlled timestamp. Remove both the artifact rows @@ -169,7 +196,49 @@ if (getJson("sr2-anchor-schema-version", 0) < 2) db.transaction(() => { }), }); setJson("sr2-anchor-schema-version", 2); -})(); +}).immediate(); + +// Early Directory builds exposed the internal name `revocationBinding` in +// ListingSummary. DACS-1 uses `revocation`; migrate persisted catalogs once so +// every public surface and restart path now emits the normative wire field. +if (getJson("listing-summary-revocation-schema-version", 0) < 1) db.transaction(() => { + type LegacyListingSummary = ListingSummary & { revocationBinding?: RevocationBinding }; + const catalog = getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); + setJson("catalog", { + ...catalog, + sellers: catalog.sellers.map((seller) => ({ + ...seller, + listings: seller.listings.map((listing) => { + const legacy = listing as LegacyListingSummary; + const { revocationBinding, ...current } = legacy; + const revocation = current.revocation ?? revocationBinding; + return { + ...current, + ...(current.status === "revoked" && revocation ? { revocation } : {}), + }; + }), + })), + }); + setJson("listing-summary-revocation-schema-version", 1); +}).immediate(); + +// Catalog authentication is deliberately narrower than the current SDK's +// buyer-local LR-1..LR-3 decision. Persist that distinction on every existing +// summary so old catalogs cannot imply transaction readiness after upgrade. +if (getJson("listing-summary-readiness-schema-version", 0) < 1) db.transaction(() => { + const catalog = getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); + setJson("catalog", { + ...catalog, + sellers: catalog.sellers.map((seller) => ({ + ...seller, + listings: seller.listings.map((listing) => ({ + ...listing, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, + })), + })), + }); + setJson("listing-summary-readiness-schema-version", 1); +}).immediate(); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -386,22 +455,31 @@ export function pruneFailureHistory(now = Date.now(), batch = 500): number { export const failureHistorySize = (): number => (db.prepare("SELECT COUNT(*) count FROM artifact_failure_history").get() as { count: number }).count; -export type ListingRejectionCode = "SELLER_CLAIM_BINDING" | "OWNER_CLAIM_BINDING"; - export function recordListingRejection( locator: string, registrationClaim: string, code: ListingRejectionCode, + coordinates?: ListingDiagnosticCoordinates, ): void { const now = Date.now(); db.prepare(`INSERT INTO listing_rejections( - locator,registration_claim,reason_code,occurrences,first_seen_at,last_seen_at - ) VALUES (?,?,?,1,?,?) + locator,registration_claim,reason_code,listing_id,listing_version,occurrences,first_seen_at,last_seen_at + ) VALUES (?,?,?,?,?,1,?,?) ON CONFLICT(locator,registration_claim) DO UPDATE SET reason_code=excluded.reason_code, + listing_id=COALESCE(excluded.listing_id,listing_rejections.listing_id), + listing_version=COALESCE(excluded.listing_version,listing_rejections.listing_version), occurrences=listing_rejections.occurrences+1, last_seen_at=excluded.last_seen_at`) - .run(locator, registrationClaim.toLowerCase(), code, now, now); + .run( + locator, + registrationClaim.toLowerCase(), + code, + coordinates?.listingId ?? null, + coordinates?.listingVersion ?? null, + now, + now, + ); } export function clearListingRejection(locator: string, registrationClaim: string): void { @@ -444,6 +522,8 @@ export interface PublicDeadLetterDiagnostic { export interface PublicListingRejectionDiagnostic { locator: string; + listingId?: string; + listingVersion?: number; code: ListingRejectionCode; message: string; occurrences: number; @@ -484,7 +564,7 @@ export interface IndexerDiagnostics { items: PublicDeadLetterDiagnostic[]; }; listingRejectionDiagnostics: { - scope: "listing-registration-binding"; + scope: "listing-admission"; total: number; byCode: Record; query: { locator: string | null; limit: number }; @@ -502,17 +582,14 @@ interface DeadLetterRow { interface ListingRejectionRow { locator: string; + listing_id: string | null; + listing_version: number | null; reason_code: ListingRejectionCode; occurrences: number; first_seen_at: number; last_seen_at: number; } -const LISTING_REJECTION_MESSAGES: Record = { - SELLER_CLAIM_BINDING: "The verified listing seller does not match the registration claim.", - OWNER_CLAIM_BINDING: "The listing anchor owner does not match the registration claim.", -}; - const publicFailure = (code: string): { code: string; message: string } => PUBLIC_FAILURES[code] ? { code, message: PUBLIC_FAILURES[code] } @@ -571,7 +648,7 @@ const readIndexerDiagnostics = db.transaction((options: IndexerDiagnosticsOption "SELECT reason_code, COUNT(*) count FROM listing_rejections GROUP BY reason_code", ).all() as Array<{ reason_code: ListingRejectionCode; count: number }>; const listingWhere = locator ? " WHERE locator = ?" : ""; - const listingStatement = db.prepare(`SELECT locator,reason_code,occurrences,first_seen_at,last_seen_at + const listingStatement = db.prepare(`SELECT locator,reason_code,listing_id,listing_version,occurrences,first_seen_at,last_seen_at FROM listing_rejections${listingWhere} ORDER BY last_seen_at DESC, locator ASC LIMIT ?`); const listingRows = (locator @@ -580,6 +657,8 @@ const readIndexerDiagnostics = db.transaction((options: IndexerDiagnosticsOption const listingHasMore = listingRows.length > limit; const listingItems = listingRows.slice(0, limit).map((row): PublicListingRejectionDiagnostic => ({ locator: row.locator, + ...(row.listing_id ? { listingId: row.listing_id } : {}), + ...(row.listing_version !== null ? { listingVersion: row.listing_version } : {}), code: row.reason_code, message: LISTING_REJECTION_MESSAGES[row.reason_code], occurrences: row.occurrences, @@ -595,7 +674,7 @@ const readIndexerDiagnostics = db.transaction((options: IndexerDiagnosticsOption query: { locator: locator ?? null, limit }, returned: items.length, hasMore, items, }, listingRejectionDiagnostics: { - scope: "listing-registration-binding", + scope: "listing-admission", total: listingRejectionTotal, byCode: Object.fromEntries(listingRejectionCounts.map((row) => [row.reason_code, row.count])), query: { locator: locator ?? null, limit }, diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 81cce82..2f5fa9a 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -118,15 +118,26 @@ export interface ListingSummary { terms?: Record; status: "active" | "revoked"; /** Required for revoked records; forbidden on active records (DACS-1 RB-3). */ - revocationBinding?: RevocationBinding; + revocation?: RevocationBinding; catalogObservedAt: number; /** Optional catalog observation; never a listing validity or trust signal. */ reachabilityHint?: ReachabilityHint; + /** + * Catalog admission is not buyer/session admission. A buyer must dereference + * the signed artifact and run the current SDK reader with its own policy and + * runtime dependencies before transacting. + */ + transactionReadiness?: DirectoryTransactionReadiness; reputationHint?: ReputationHint; /** Directory extension: machine-readable pointer to a verifier profile envelope. */ inspection?: DirectoryInspectionAffordance; } +export interface DirectoryTransactionReadiness { + disposition: "unassessed"; + reason: string; +} + export interface ReachabilityHint { status: "reachable" | "unreachable" | "unknown"; checkedAt: number; diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 8fdf135..8960ca7 100644 --- a/reference-implementations/dacs-directory/src/catalog/wellknown.ts +++ b/reference-implementations/dacs-directory/src/catalog/wellknown.ts @@ -12,9 +12,11 @@ * lists anchors it doesn't own is caught by the indexer (listing.agentId * must equal the claimed seller). Per-domain failures never poison the pass. */ -import { sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { encodeAddressSegment, sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { isRevocationBinding } from "@kynesyslabs/dacs/artifacts"; import { boundedPublicHttpsRequest, isPrivateAddress, validatePublicHttpsUrl } from "./boundedHttps.js"; import { verifyBundleBinding } from "./bundleBinding.js"; +import { canonicalDemosAgentClaim } from "./claimRef.js"; import type { BundleBinding } from "./types.js"; export { isPrivateAddress } from "./boundedHttps.js"; @@ -39,14 +41,70 @@ interface ListingIndex { indexVersion?: string; generatedAt?: number; seller?: string; - listings?: Array<{ - listingId?: string; - version?: number; - contentHash?: string; - anchor?: { kind?: string; locator?: string }; - summary?: { title?: string }; - status?: "active" | "revoked"; - }>; + listings?: unknown; +} + +type ListingIndexEntry = { + listingId?: unknown; + version?: unknown; + contentHash?: unknown; + anchor?: { kind?: unknown; locator?: unknown }; + status?: unknown; + revocation?: unknown; +}; + +export type ListingIndexProjection = + | { ok: true; listingAnchors: string[]; contentHashes: Record } + | { ok: false; error: string }; + +/** Validate RB-3 coherence and retain only active anchors for indexing. */ +export function projectActiveListingIndexEntries( + value: unknown, + seller: string, +): ListingIndexProjection { + if (!Array.isArray(value) || value.length > 200) { + return { ok: false, error: "listings.json must contain at most 200 listings" }; + } + const listingAnchors: string[] = []; + const contentHashes: Record = {}; + for (const raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "listings.json contains an invalid listing entry" }; + } + const entry = raw as ListingIndexEntry; + const locator = entry.anchor?.locator; + if ( + typeof entry.listingId !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(entry.listingId) || + !Number.isSafeInteger(entry.version) || Number(entry.version) < 1 || + entry.anchor?.kind !== "storage-program" || typeof locator !== "string" || !/^stor-[0-9a-f]{40}$/.test(locator) || + typeof entry.contentHash !== "string" || !/^[0-9a-f]{64}$/.test(entry.contentHash) || + (entry.status !== "active" && entry.status !== "revoked") + ) return { ok: false, error: "listings.json contains an invalid listing entry" }; + + if (entry.status === "active") { + if (entry.revocation !== undefined) { + return { ok: false, error: "an active listing entry must not carry revocation" }; + } + } else { + const revocation = entry.revocation; + const expectedLogicalAddress = + `dacs1-revoked:${encodeAddressSegment(seller)}:${entry.listingId}:v${entry.version}`; + if ( + !isRevocationBinding(revocation) || + revocation.sellerPrimaryClaim !== seller || + revocation.listingId !== entry.listingId || + revocation.listingVersion !== entry.version || + revocation.listingContentHash !== entry.contentHash || + revocation.logicalAddress !== expectedLogicalAddress + ) { + return { ok: false, error: "a revoked listing entry has no matching revocation binding" }; + } + continue; + } + listingAnchors.push(locator); + contentHashes[locator] = entry.contentHash; + } + return { ok: true, listingAnchors, contentHashes }; } export function normalizeSubmittedDomain(domain: string): string { @@ -99,26 +157,13 @@ export async function crawlDomain(domain: string): Promise 200) { - return { domain, error: "listings.json must contain at most 200 listings" }; - } - - const listingAnchors: string[] = []; - const contentHashes: Record = {}; - for (const entry of idx.listings ?? []) { - const locator = entry.anchor?.locator; - if ( - entry.status === "revoked" || !entry.listingId || !Number.isSafeInteger(entry.version) || Number(entry.version) < 1 || - !locator || !/^stor-[0-9a-f]{40}$/.test(locator) || - !entry.contentHash || !/^[0-9a-fA-F]{64}$/.test(entry.contentHash) - ) continue; - listingAnchors.push(locator); - contentHashes[locator] = entry.contentHash.toLowerCase(); - } + const projection = projectActiveListingIndexEntries(idx.listings, seller); + if (!projection.ok) return { domain, error: projection.error }; + const { listingAnchors, contentHashes } = projection; const rawName = (card.body as { name?: unknown })?.name; const displayName = typeof rawName === "string" && rawName.length <= 100 ? rawName : undefined; const bundleBindings: BundleBinding[] = []; diff --git a/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx b/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx index 0a46db2..ea3168b 100644 --- a/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx +++ b/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx @@ -11,6 +11,18 @@ interface Status { syncedToTx: number; chainLatestTx: number | null; txsBehind: number | null; + indexer?: { + listingRejectionDiagnostics?: { + total: number; + items: Array<{ + locator: string; + listingId?: string; + listingVersion?: number; + code: string; + message: string; + }>; + }; + }; } function ago(ts: number): string { @@ -49,17 +61,35 @@ export default function CatalogStatus() { ? "in sync with chain" : `${behind.toLocaleString()} tx${behind === 1 ? "" : "s"} behind chain`; + const rejected = status.indexer?.listingRejectionDiagnostics; return ( - - - {label} · indexed {ago(status.generatedAt)} - +
    + + + {label} · indexed {ago(status.generatedAt)} + + {rejected && rejected.total > 0 && ( +
    + {rejected.total.toLocaleString()} listing candidate{rejected.total === 1 ? "" : "s"} need seller attention +

    These candidates are excluded from discovery. Republish a corrected version with the current SDK, then let the Directory reindex it.

    +
      + {rejected.items.map((item) => ( +
    • + {item.listingId ?? item.locator}{item.listingVersion ? ` · version ${item.listingVersion}` : ""} + {item.message} + {item.code} +
    • + ))} +
    +
    + )} +
    ); } diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 5fcb84d..b59d7f7 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -721,7 +721,7 @@ export default function TryDacs() { try { if (isProcurementSel && selectedProfile) { const deadline = Date.now() + 12 * 60_000; - // The Directory is the discovery surface. Pass its verified listing + // The Directory is the discovery surface. Pass its authenticated catalog candidate // pointer to the Butler; the gateway independently dereferences and // verifies the signed DACS-1 artifact before negotiation. const request: Record = { profileId: selectedProfile.id, ...parsed, paymentRail: selectedPaymentRail }; diff --git a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx index abdc6e7..abee4d3 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import "@/src/shims/buffer"; import { ed25519Verify, publicKeyFromRaw, dacsXSeparator } from "@kynesyslabs/dacs/crypto"; // Pure module — safe for client bundles (no substrate/demosdk in its chain). -import { verifySignedArtifact } from "@/vendor/dacs-sdk/dist/agent/signedArtifact.js"; +import { verifySignedArtifact } from "@kynesyslabs/dacs"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index 298d77c..8075961 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -11,15 +11,11 @@ import { useState } from "react"; // Side-effect: patches the browser Buffer polyfill with base64url support // (the SDK decodes signature bytes with Buffer.from(x, "base64url")). import "@/src/shims/buffer"; -// Import ONLY pure modules: the package barrel re-exports createAgent, whose -// lazy `import("../substrate")` gets statically traced by Next's bundler and -// drags demosdk (node-only) into the client bundle. (SDK finding: a pure -// "./verify" subpath export would fix this properly — see dacs-sdk#14.) import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { verifyBundleCore, type BundleVerification, -} from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +} from "@kynesyslabs/dacs"; import { bundleMatchesRegisteredAnchor, hasRequiredBundleSignatures, @@ -27,17 +23,17 @@ import { refsPassStrictPolicy, type ResolvedArtifact, } from "@/src/catalog/bundlePolicy"; +import { legacySessionAnchorName } from "@/src/catalog/legacySessionAnchorName"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; return hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; }; -// Mirrors the SDK's sessionAnchorName (not exported publicly — dacs-sdk#14). const anchorName: Record string> = { - "dacs-3-agreement": (j) => `dacs3:agreement:${j}`, - "dacs-4-evidence": (j) => `dacs4:evidence:${j}`, - "dacs-2-verifyresult": (j) => `dacs2:verifyrecord:${j}`, + "dacs-3-agreement": legacySessionAnchorName.agreement, + "dacs-4-evidence": legacySessionAnchorName.evidence, + "dacs-2-verifyresult": legacySessionAnchorName.vet, }; async function fetchArtifact(params: string): Promise | null> { diff --git a/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts index e53a271..e15bc0d 100644 --- a/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts +++ b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts @@ -12,6 +12,8 @@ export type PendingListingPublication = { contentHash: string; signedListing: Record; transaction: Record | null; + transactionRef?: string; + anchorReceipt?: Record; registration: Record; stage: PendingPublicationStage; createdAt: number; @@ -25,6 +27,7 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu const signedListing = record(pending?.signedListing); const registration = record(pending?.registration); const transaction = pending?.transaction === null ? null : record(pending?.transaction); + const anchorReceipt = pending?.anchorReceipt === undefined ? undefined : record(pending.anchorReceipt); if ( pending?.version !== 1 || typeof pending.claim !== "string" || !/^did:demos:agent:[0-9a-f]{64}$/.test(pending.claim) || @@ -37,6 +40,10 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu !registration || registration.primaryClaim !== pending.claim || !Array.isArray(registration.listingAnchors) || !registration.listingAnchors.includes(pending.anchorAddress) || (pending.transaction !== null && !transaction) || + (pending.transactionRef !== undefined && ( + typeof pending.transactionRef !== "string" || !/^[0-9a-f]{64}$/.test(pending.transactionRef) + )) || + (pending.anchorReceipt !== undefined && !anchorReceipt) || (pending.stage !== "broadcast-uncertain" && pending.stage !== "confirming" && pending.stage !== "registering") || !Number.isSafeInteger(pending.createdAt) || Number(pending.createdAt) <= 0 ) return null; @@ -51,6 +58,8 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu contentHash: pending.contentHash, signedListing, transaction, + ...(typeof pending.transactionRef === "string" ? { transactionRef: pending.transactionRef } : {}), + ...(anchorReceipt ? { anchorReceipt } : {}), registration, stage: pending.stage, createdAt: Number(pending.createdAt), diff --git a/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts b/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts new file mode 100644 index 0000000..96ed6fc --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts @@ -0,0 +1,13 @@ +/** + * Server-only compatibility seam for pure Demos finality-evidence helpers. + * Importing the SDK substrate barrel also loads the optional demosdk adapter + * and its unrelated multichain/ZK tree, which the Directory does not use. + */ +export { + assertDemosWriteEvidence, + demosSignedTransactionProofHash, + demosWriteEvidenceToAnchorReceipt, +} from "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js"; +export type { + DemosWriteEvidence, +} from "../vendor/dacs-sdk/dist/substrate/SubstrateAdapter.js"; diff --git a/reference-implementations/dacs-directory/src/sdkListingValidation.ts b/reference-implementations/dacs-directory/src/sdkListingValidation.ts new file mode 100644 index 0000000..3718f08 --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkListingValidation.ts @@ -0,0 +1,12 @@ +/** + * Server-only compatibility seam for the current SDK's public Listing reader + * and publication gates. Keep this separate from sdkVerification.ts: the + * Listing implementation intentionally uses node:net for outbound-address + * policy and must never enter the browser verification bundle. + */ +export { + resolveListingPayloadVerificationCapability, + resolveListingRails, + validateListingArtifact, + type ListingValidationDeps, +} from "../vendor/dacs-sdk/dist/agent/listingValidation.js"; diff --git a/reference-implementations/dacs-directory/src/sdkVerification.ts b/reference-implementations/dacs-directory/src/sdkVerification.ts new file mode 100644 index 0000000..f39ac93 --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkVerification.ts @@ -0,0 +1,15 @@ +/** + * One compatibility seam for the SDK's public verification exports. + * + * The current SDK exposes these names from its top-level barrel, but that + * barrel also statically re-exports optional Node/multi-chain modules. Resolve + * the public names through their pure implementations until the SDK ships a + * browser-safe public verification subpath. + */ +export { + verifyBundleCore, + type BundleVerification, +} from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +export { + verifySignedArtifact, +} from "../vendor/dacs-sdk/dist/agent/signedArtifact.js"; diff --git a/reference-implementations/dacs-directory/src/shims/node-util.ts b/reference-implementations/dacs-directory/src/shims/node-util.ts new file mode 100644 index 0000000..74d6172 --- /dev/null +++ b/reference-implementations/dacs-directory/src/shims/node-util.ts @@ -0,0 +1,8 @@ +/** Browser subset used by the SDK's pure verification modules. */ +export const types = { + // Artifacts arrive through Response.json(), and dependency objects are + // constructed inside the component, so no caller-owned Proxy crosses this + // browser verification boundary. + isProxy: (_value: unknown): boolean => false, + isUint8Array: (value: unknown): value is Uint8Array => value instanceof Uint8Array, +}; diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 12b8293..ec5a439 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -25,7 +25,7 @@ const base = { name: "Metered AP2 service", description: "A metered service settled through an operator-gated AP2 provider.", rails: ["ap2:stripe-paymentintents"], - delivery: ["deliver-attested-payload"], + delivery: ["deliver-storage-program"], pricing: { kind: "metered", amount: "0.02", @@ -44,7 +44,7 @@ const request = (body: Record) => new NextRequest( }, ); -test("publisher builds a verifiable metered listing with the AP2 rail/phase binding", async () => { +test("publisher uses PA-1 rail resolution and signs the exact staged Listing before creating a transaction", async () => { const identityResponse = await POST(request(base)); assert.equal(identityResponse.status, 200); const identity = await identityResponse.json() as { @@ -79,47 +79,78 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind identitySignature, })); assert.equal(response.status, 200); - const built = await response.json() as { + const preview = await response.json() as { listing: Record; message: string; contentHash: string; logicalAddress: string; programName: string; - anchorAddress: string; exists: boolean; - tx: { content: { nonce: number; data: [string, Record] } }; + publicationReady: boolean; + railResolution: { disposition: string; authorityBasis: string }; + payloadCapability: { disposition: string }; + tx: null; }; - assert.deepEqual(built.listing.pricing, { + assert.equal(preview.publicationReady, false); + assert.equal(preview.tx, null, "an unsigned preview must not contain a broadcastable transaction"); + assert.equal(preview.railResolution.disposition, "verified"); + assert.equal(preview.railResolution.authorityBasis, "pa1-in-code"); + assert.equal(preview.payloadCapability.disposition, "not-applicable"); + assert.deepEqual(preview.listing.pricing, { kind: "metered", unitPrice: { amount: "0.02", currency: "USD" }, unit: "API call", minTotal: { amount: "1", currency: "USD" }, }); - assert.deepEqual(built.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); - assert.deepEqual(built.listing.pipeline, [ + assert.deepEqual(preview.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); + assert.deepEqual( + (preview.listing.offering as Record).deliverable, + { kind: "storage-program", accessModel: "public" }, + ); + assert.deepEqual(preview.listing.pipeline, [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-ap2", parameters: { rail: "ap2:stripe-paymentintents" } }, - { kind: "deliver-attested-payload" }, + { kind: "deliver-storage-program" }, ]); + + const listingSignature = Buffer.from( + ed25519Sign(Buffer.from(preview.message, "utf8"), privateKey), + ).toString("hex"); + const finalResponse = await POST(request({ + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature, + listingSignature, + })); + assert.equal(finalResponse.status, 200); + const built = await finalResponse.json() as { + listing: Record; + contentHash: string; + logicalAddress: string; + programName: string; + anchorAddress: string; + exists: boolean; + publicationReady: boolean; + tx: { content: { nonce: number; data: [string, Record] } }; + }; + assert.equal(built.publicationReady, true); + assert.equal(built.contentHash, preview.contentHash); assert.equal(built.exists, false); assert.equal(built.logicalAddress, `dacs1:did%3Ademos%3Aagent%3A${keyHex}:metered-ap2:v1`); assert.ok(!built.programName.includes(":"), "the producer-held Demos name must be colon-free"); + assert.equal(built.programName, built.logicalAddress.replaceAll(":", "%3A")); assert.equal(built.tx.content.nonce, 1); assert.equal(built.tx.content.data[1].salt, "", "SDK #70 uses the live empty-salt convention"); + assert.deepEqual(built.tx.content.data[1].data, built.listing); + assert.equal((built.tx.content.data[1].metadata as Record).contentHash, built.contentHash); assert.equal( built.anchorAddress, deriveStorageAddress(`0x${keyHex}`, built.programName, 1, LIVE_STORAGE_SALT), ); assert.equal(built.contentHash.length, 64); - const listingSignature = Buffer.from( - ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), - ).toString("hex"); - assert.ok(await verifyListing({ - ...built.listing, - signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, - })); + assert.ok(await verifyListing(built.listing)); } finally { globalThis.fetch = originalFetch; } @@ -144,7 +175,12 @@ test("publisher recovers an immutable owner-bound listing instead of creating a return new Response(null, { status: 404 }); }; try { - const builtResponse = await POST(request(finalInput)); + const previewResponse = await POST(request(finalInput)); + const preview = await previewResponse.json() as { message: string }; + const listingSignature = Buffer.from( + ed25519Sign(Buffer.from(preview.message, "utf8"), privateKey), + ).toString("hex"); + const builtResponse = await POST(request({ ...finalInput, listingSignature })); assert.equal(builtResponse.status, 200); const built = await builtResponse.json() as { listing: Record; @@ -153,13 +189,7 @@ test("publisher recovers an immutable owner-bound listing instead of creating a programName: string; anchorAddress: string; }; - const listingSignature = Buffer.from( - ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), - ).toString("hex"); - const signedListing = { - ...built.listing, - signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, - }; + const signedListing = built.listing; globalThis.fetch = async (input, init) => { if (init?.method === "POST") { @@ -209,6 +239,22 @@ test("publisher rejects a metered listing without its deterministic unit", async assert.match(String((await response.json()).error), /metered pricing needs a unit/); }); +test("browser publisher refuses attested payloads without seller-runtime production capability", async () => { + const identityResponse = await POST(request({ ...base, delivery: ["deliver-attested-payload"] })); + const identity = await identityResponse.json() as { identityMessage: string; identityPresentedAt: number }; + const identitySignature = Buffer.from( + ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey), + ).toString("hex"); + const response = await POST(request({ + ...base, + delivery: ["deliver-attested-payload"], + identityPresentedAt: identity.identityPresentedAt, + identitySignature, + })); + assert.equal(response.status, 409); + assert.match(String((await response.json()).error), /cannot prove.*production capability/i); +}); + test("publisher refuses a new write when existing-publication lookup is indeterminate", async () => { const identityResponse = await POST(request(base)); const identity = await identityResponse.json() as { identityMessage: string; identityPresentedAt: number }; diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 37882e2..0baf476 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { NextRequest } from "next/server"; -import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { canonicalize, contentHash, listingAddress, sha256Hex } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; const { POST } = await import("../app/api/dacs/confirm-listing/route.js"); @@ -12,7 +12,6 @@ const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer const sellerClaim = `did:demos:agent:${keyHex}`; const owner = `0x${keyHex}`; const anchorAddress = `stor-${"ab".repeat(20)}`; -const programName = "dacs1-ZGFjczEtdmVyaWZpZWQ"; const identity = { bundleVersion: "1", presentedBy: sellerClaim, @@ -40,7 +39,11 @@ const scope = { description: "A deterministic verified service.", category: "services.other", tags: [], - deliverable: { kind: "attested-payload", payloadFormat: "application/json" }, + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ @@ -63,6 +66,43 @@ const listing = { value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("hex"), }, }; +const logicalAddress = listingAddress(sellerClaim, scope.listingId, scope.listingVersion); +const programName = logicalAddress.replaceAll(":", "%3A"); +const transactionHash = "cd".repeat(32); +const metadata = { + logicalAddress, + contentHash: listingHash, + envelopeHash: sha256Hex(canonicalize(listing)), +}; +const transaction = { + id: 10, + status: "confirmed", + type: "storageProgram", + hash: transactionHash, + blockNumber: 5, + to: anchorAddress, + content: { + type: "storageProgram", + from: owner, + to: anchorAddress, + amount: 0, + data: ["storageProgram", { + operation: "CREATE_STORAGE_PROGRAM", + storageAddress: anchorAddress, + programName, + encoding: "json", + data: listing, + metadata, + acl: { mode: "public" }, + salt: "", + storageLocation: "onchain", + }], + nonce: 7, + timestamp: 1_786_360_000_000, + transaction_fee: { network_fee: 0, rpc_fee: 0, additional_fee: 0, rpc_address: null }, + }, + signature: "signed-envelope", +}; const coordinates = { anchorAddress, programName, @@ -78,19 +118,51 @@ const request = (body: Record) => new NextRequest( test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = async () => Response.json({ success: true, data: listing, owner, programName }); + globalThis.fetch = async (input, init) => { + if (init?.method !== "POST") { + return Response.json({ + success: true, + data: listing, + owner, + programName, + storageAddress: anchorAddress, + metadata, + createdByTx: transactionHash, + interactionTxs: [], + }); + } + const rpc = JSON.parse(String(init.body)) as { params: Array<{ message: string; data: Record }> }; + const call = rpc.params[0]!; + if (call.message === "getTransactions") return Response.json({ result: 200, response: [transaction] }); + if (call.message === "getBlockByNumber" && call.data.blockNumber === 5) { + return Response.json({ + result: 200, + response: { + status: "confirmed", + number: 5, + hash: "block-5", + content: { timestamp: 1_786_360_000, ordered_transactions: [transactionHash] }, + validation_data: { validators: ["fixture-validator"], quorum: 1 }, + }, + }); + } + if (call.message === "getBlockByNumber" && call.data.blockNumber === 0) { + return Response.json({ result: 200, response: { number: 0, hash: "fixture-genesis" } }); + } + return Response.json({ result: 500, response: null }); + }; try { const response = await POST(request(coordinates)); assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { - confirmed: true, - state: "verified", - anchorAddress, - contentHash: listingHash, - sellerClaim, - listingId: scope.listingId, - listingVersion: 1, - }); + const body = await response.json() as Record; + assert.equal(body.confirmed, true); + assert.equal(body.state, "finalized-and-verified"); + assert.equal(body.anchorAddress, anchorAddress); + assert.equal(body.contentHash, listingHash); + assert.equal(body.sellerClaim, sellerClaim); + assert.equal(body.listingId, scope.listingId); + assert.equal(body.listingVersion, 1); + assert.equal((body.anchorReceipt as Record).state, "finalized"); } finally { globalThis.fetch = originalFetch; } diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 28d7574..efe55e4 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -6,7 +6,11 @@ import { artifactHash, buildCurrentEvidenceGraph, signedScope } from "../src/cat import { agreementRail } from "../src/catalog/agreementMetadata.js"; import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "../src/catalog/currentReconciliation.js"; import { deriveIdentityTier, type RecipePolicy } from "../src/catalog/identityVerification.js"; -import { indexRegistration, listingBindingRejection } from "../src/catalog/indexer.js"; +import { + indexRegistration, + listingBindingRejection, + listingIsCurrentlyEffective, +} from "../src/catalog/indexer.js"; import { deriveSellerReputation, isNeutralCancellation } from "../src/catalog/reputation.js"; import { verifyListing } from "../src/catalog/listingVerification.js"; import { logicalBundleAddress } from "../src/catalog/bundleBinding.js"; @@ -38,7 +42,7 @@ async function vector(jobId = "job-1", offset = 0) { const listingScope: Obj = { dacsVersion: "1", listingVersion: 1, listingId: "svc", requiredCapabilities: ["SR-2"], seller: { identity: { bundleVersion: "1", presentedBy: dids[1], presentedAt: 1, claims: [{ ref: dids[1] }], presentation: { kind: "per-claim", signatures: [] } }, displayName: "seller" }, - offering: { title: "test", description: "test service", category: "services.test", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + offering: { title: "test", description: "test service", category: "services.test", tags: [], deliverable: { kind: "attested-payload", payloadFormat: "application/json", verificationMethod: { kind: "self-signed" } } }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "pay-dem" } }, { kind: "deliver-attested-payload" }], pricing: { kind: "fixed", price: { amount: "1.25", currency: "DEM", unit: "job" } }, acceptedRails: [{ railId: "pay-dem" }], terms: {}, validity: { notBefore: 1 }, @@ -216,6 +220,15 @@ test("listing binding compares canonical §6.3.1 forms, not lowercased strings", assert.equal(listingBindingRejection(dids[1], owner, `0x${hexTail}`), "SELLER_CLAIM_BINDING"); }); +test("listing discovery enforces both edges of the signed validity window", () => { + const now = 1_000; + assert.equal(listingIsCurrentlyEffective({ validity: { notBefore: now } }, now), true); + assert.equal(listingIsCurrentlyEffective({ validity: { notBefore: now + 1 } }, now), false); + assert.equal(listingIsCurrentlyEffective({ validity: { notBefore: 1, notAfter: now } }, now), true); + assert.equal(listingIsCurrentlyEffective({ validity: { notBefore: 1, notAfter: now - 1 } }, now), false); + assert.equal(listingIsCurrentlyEffective({}, now), true, "explicit legacy Listings have no validity field"); +}); + function dealRecord( deal: RegisteredDeal, reconciled: ReturnType, diff --git a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts index 52a30be..e06702c 100644 --- a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts +++ b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts @@ -111,15 +111,24 @@ test("storage cause diagnostics are actionable without claiming DACS absence", ( test("listing binding rejections are persistent, public-safe, filterable, and recoverable", () => { const target = locator("7"); const claim = `did:demos:agent:${"7".repeat(64)}`; - store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); - store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); + store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING", { + listingId: "seller-service", + listingVersion: 3, + }); + store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING", { + listingId: "seller-service", + listingVersion: 3, + }); const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }); + assert.equal(diagnostics.listingRejectionDiagnostics.scope, "listing-admission"); assert.equal(diagnostics.listingRejectionDiagnostics.total, 1); assert.equal(diagnostics.listingRejectionDiagnostics.returned, 1); assert.equal(diagnostics.listingRejectionDiagnostics.byCode.OWNER_CLAIM_BINDING, 1); assert.deepEqual(diagnostics.listingRejectionDiagnostics.items[0], { locator: target, + listingId: "seller-service", + listingVersion: 3, code: "OWNER_CLAIM_BINDING", message: "The listing anchor owner does not match the registration claim.", occurrences: 2, @@ -132,6 +141,20 @@ test("listing binding rejections are persistent, public-safe, filterable, and re assert.equal(store.indexerDiagnostics({ deadLetterLocator: target }).listingRejectionDiagnostics.returned, 0); }); +test("normative listing admission failures expose stable public-safe diagnostics", () => { + const target = locator("8"); + const claim = `did:demos:agent:${"8".repeat(64)}`; + store.recordListingRejection(target, claim, "VERIFICATION_METHOD_INVALID"); + + const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }) + .listingRejectionDiagnostics; + assert.equal(diagnostics.scope, "listing-admission"); + assert.equal(diagnostics.byCode.VERIFICATION_METHOD_INVALID, 1); + assert.equal(diagnostics.items[0].code, "VERIFICATION_METHOD_INVALID"); + assert.match(diagnostics.items[0].message, /registered structured variant/); + assert.doesNotMatch(JSON.stringify(diagnostics), new RegExp(claim)); +}); + test("cursor progress diagnostics distinguish caught-up, stalled, and unknown cursors", () => { const now = 1_000_000; assert.equal(cursorStallThresholdSeconds("60"), 60); diff --git a/reference-implementations/dacs-directory/test/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index 2b355b6..6edbad9 100644 --- a/reference-implementations/dacs-directory/test/discovery-contract.test.ts +++ b/reference-implementations/dacs-directory/test/discovery-contract.test.ts @@ -89,12 +89,14 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { assert.equal(listingSummarySchema.properties.inspection.properties.artifactType.const, "directory-service-profile"); assert.ok(listingSummarySchema.properties.inspection.properties.maturity.enum.includes("listed")); assert.equal(listingSummarySchema.properties.inspection.properties.href.format, "uri-reference"); + assert.ok(listingSummarySchema.required.includes("transactionReadiness")); + assert.equal(listingSummarySchema.properties.transactionReadiness.properties.disposition.const, "unassessed"); assert.ok(listingSummarySchema.properties.artifactProfile.enum.includes("fixture-listing")); assert.ok(listingSummarySchema.properties.pricing.properties.kind.enum.includes("metered")); assert.equal(listingSummarySchema.properties.pricing.properties.minTotalHint.type, "string"); - assert.ok(listingSummarySchema.allOf[0].then.required.includes("revocationBinding")); + assert.ok(listingSummarySchema.allOf[0].then.required.includes("revocation")); assert.equal( - listingSummarySchema.properties.revocationBinding.properties.markerContentHash.pattern, + listingSummarySchema.properties.revocation.properties.markerContentHash.pattern, "^[0-9a-f]{64}$", ); const filters = document.paths["/api/dacs/listings"].get.parameters.map((parameter) => parameter.name); @@ -116,7 +118,16 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { assert.ok(catalogStatusSchema.required.includes("secondsSinceCursorAdvanced")); assert.ok(catalogStatusSchema.required.includes("cursorStalled")); assert.equal(deadLetterDiagnosticSchema.properties.retryState.const, "exhausted"); - assert.ok(listingRejectionDiagnosticSchema.properties.code.enum.includes("OWNER_CLAIM_BINDING")); + assert.deepEqual([...listingRejectionDiagnosticSchema.properties.code.enum], [ + "SELLER_CLAIM_BINDING", + "OWNER_CLAIM_BINDING", + "NORMATIVE_LISTING_INVALID", + "VERIFICATION_METHOD_INVALID", + "LISTING_SIGNATURE_INVALID", + "IDENTITY_PRESENTATION_INVALID", + "LEGACY_LISTING_INVALID", + "DECLARED_CONTENT_HASH_MISMATCH", + ]); assert.equal(indexerScanRunSchema.additionalProperties, false); assert.ok(!("error" in indexerScanRunSchema.properties)); }); diff --git a/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json b/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json new file mode 100644 index 0000000..5f64438 --- /dev/null +++ b/reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json @@ -0,0 +1,20 @@ +[ + { + "listingId": "audit-negotiator-x402", + "listingVersion": 3, + "locator": "stor-77bbb76304a3809858786ac816f693db4ebdc238", + "verificationMethod": "self-signed" + }, + { + "listingId": "oracle-data-x402", + "listingVersion": 3, + "locator": "stor-4b7765d9077b5be93b4538541245e95f9bfff4fc", + "verificationMethod": "seller-signature-source-attestation-and-request-hash" + }, + { + "listingId": "dd-research-x402", + "listingVersion": 2, + "locator": "stor-624fae262d7ad2b809b1fc1ec56b421e4e46d35e", + "verificationMethod": "seller-signature-cited-source-attestations-and-request-hash" + } +] diff --git a/reference-implementations/dacs-directory/test/inspection.test.ts b/reference-implementations/dacs-directory/test/inspection.test.ts index 4d8a117..3fe2d06 100644 --- a/reference-implementations/dacs-directory/test/inspection.test.ts +++ b/reference-implementations/dacs-directory/test/inspection.test.ts @@ -80,6 +80,8 @@ test("directory service profile envelope is listed-only and does not claim sourc assert.equal(envelope.maturity, envelope.artifact.maturityProfile.maturity); assert.equal(envelope.artifact.maturityProfile.noReputationClaim, true); assert.equal(envelope.artifact.maturityProfile.noLivePaymentClaim, true); + assert.equal(envelope.artifact.transactionReadiness.disposition, "unassessed"); + assert.match(envelope.artifact.transactionReadiness.reason, /current SDK Listing reader/); assert.deepEqual(envelope.artifact.limitations, [ "roster maturity hint", "not reputation evidence", @@ -96,5 +98,6 @@ test("listing discovery can add the inspection affordance without changing the s assert.equal(withInspection.anchor.locator, listing.anchor.locator); assert.equal(withInspection.inspection?.artifactType, "directory-service-profile"); assert.equal(withInspection.inspection?.href, inspectServicePath(listing)); + assert.equal(withInspection.transactionReadiness?.disposition, "unassessed"); assert.equal(listing.inspection, undefined); }); diff --git a/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts b/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts new file mode 100644 index 0000000..c1e257d --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-listing-summary-migration-")); +const seller = `did:demos:agent:${"8".repeat(64)}`; +const listingId = "listing-1"; +const listingContentHash = "a".repeat(64); +const revocationBinding = { + sellerPrimaryClaim: seller, + listingId, + listingVersion: 1, + listingContentHash, + logicalAddress: `dacs1-revoked:${encodeURIComponent(seller)}:${listingId}:v1`, + markerAnchor: { kind: "storage-program", locator: `stor-${"7".repeat(40)}` }, + markerContentHash: "b".repeat(64), +}; +const seeded = new Database(join(dataDirectory, "directory.sqlite")); +seeded.exec(` + CREATE TABLE kv_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO kv_state(key,value_json,updated_at) VALUES ('schema-version','1',0); + INSERT INTO kv_state(key,value_json,updated_at) VALUES ('sr2-anchor-schema-version','2',0); +`); +seeded.prepare("INSERT INTO kv_state(key,value_json,updated_at) VALUES ('catalog',?,0)").run(JSON.stringify({ + catalogVersion: "1", + generatedAt: 1_000, + sellers: [{ + primaryClaim: seller, + displayName: "migration seller", + cci: [], + listings: [{ + listingId, + version: 1, + contentHash: listingContentHash, + anchor: { kind: "storage-program", locator: `stor-${"9".repeat(40)}` }, + seller: { primaryClaim: seller, displayName: "migration seller" }, + offering: { title: "test", category: "services.test", tags: [] }, + pricing: {}, + status: "revoked", + revocationBinding, + catalogObservedAt: 1, + }], + deals: [], + reputation: { completed: 0, bundleCount: 0, totalAgreements: 0, completionRate: null }, + registeredAt: 1, + lastIndexedAt: 1, + }], +})); +seeded.close(); + +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const store = await import("../src/catalog/store.js"); + +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +test("persisted revocationBinding summaries migrate to normative revocation once", () => { + const listing = store.loadCatalog().sellers[0]?.listings[0]; + assert.deepEqual(listing?.revocation, revocationBinding); + assert.equal("revocationBinding" in (listing ?? {}), false); +}); diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 70a04e9..5ce29f1 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -1,14 +1,25 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; +import { isListing } from "@kynesyslabs/dacs/artifacts"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { verifyListing } from "../src/catalog/listingVerification.js"; +import { verifyListing, verifyListingResult } from "../src/catalog/listingVerification.js"; const seed = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); const privateKey = privateKeyFromSeed(seed); const publicKeyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex"); const claim = `did:demos:agent:${publicKeyHex}`; +const liveInvalidMethods = JSON.parse(readFileSync( + new URL("./fixtures/live-invalid-verification-methods.json", import.meta.url), + "utf8", +)) as Array<{ + listingId: string; + listingVersion: number; + locator: string; + verificationMethod: string; +}>; function signMessage(message: string): string { return Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); @@ -19,7 +30,9 @@ function signedCurrentListing( signingClaim = claim, ): Record { const identity: Record = { + bundleVersion: "1", presentedBy: signingClaim, + presentedAt: 1, claims: [{ ref: signingClaim, kind: "signing-key" }], }; identity.presentation = { @@ -45,7 +58,7 @@ function signedCurrentListing( tags: ["sig5"], deliverable: { kind: "storage-program" }, }, - buyerRequirement: { kind: "none" }, + buyerRequirement: { requirementVersion: "1", required: [] }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, @@ -120,6 +133,61 @@ test("verifyListing refuses unknown executable phase kinds even with a valid sig assert.equal(await verifyListing(listing), null); }); +test("live x402 string verification methods fail closed under the normative SDK validator", async () => { + for (const fixture of liveInvalidMethods) { + const listing = signedCurrentListing({ + listingId: fixture.listingId, + listingVersion: fixture.listingVersion, + offering: { + title: fixture.listingId, + description: `Regression fixture for ${fixture.locator}.`, + category: "services.test", + tags: ["x402"], + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: fixture.verificationMethod, + }, + }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-x402", parameters: { rail: "pay-x402" } }, + { kind: "deliver-attested-payload" }, + ], + }); + + assert.equal(isListing(listing), false, fixture.listingId); + assert.deepEqual(await verifyListingResult(listing), { + ok: false, + code: "VERIFICATION_METHOD_INVALID", + }); + assert.equal(await verifyListing(listing), null); + } + + const structured = signedCurrentListing({ + offering: { + title: "Structured verification method", + description: "A registered DACS-2 verification-method variant.", + category: "services.test", + tags: ["x402"], + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, + }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-x402", parameters: { rail: "pay-x402" } }, + { kind: "deliver-attested-payload" }, + ], + }); + assert.equal(isListing(structured), true); + assert.equal((await verifyListingResult(structured)).ok, true); +}); + test("verifyListing requires exactly one adjacent supported commitment phase", async () => { const payeeBound = signedCurrentListing({ pipeline: [ @@ -179,7 +247,10 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy assert.ok(await verifyListing(listing)); assert.ok(await verifyListing(signedCurrentListing({ pricing: metered, - pipeline: [{ kind: "negotiate-rfq" }, ...pipeline.slice(1)], + pipeline: [{ + kind: "negotiate-rfq", + parameters: { maxTurns: 2, timeoutSec: 60 }, + }, ...pipeline.slice(1)], acceptedRails: [{ railId: "ap2:stripe-paymentintents" }], }))); assert.equal(await verifyListing(signedCurrentListing({ diff --git a/reference-implementations/dacs-directory/test/security.test.ts b/reference-implementations/dacs-directory/test/security.test.ts index 65632c8..d29dfdb 100644 --- a/reference-implementations/dacs-directory/test/security.test.ts +++ b/reference-implementations/dacs-directory/test/security.test.ts @@ -23,6 +23,7 @@ import { crawlDomain, isPrivateAddress, normalizeSubmittedDomain, + projectActiveListingIndexEntries, } from "../src/catalog/wellknown.js"; const claim = `did:demos:agent:${"a".repeat(64)}`; @@ -190,6 +191,46 @@ test("well-known URL policy rejects unsafe schemes and address ranges", async () assert.equal("error" in result, true); }); +test("well-known listing indexes enforce normative revocation coherence", () => { + const listingId = "service-1"; + const contentHash = "b".repeat(64); + const active = { + listingId, + version: 1, + contentHash, + anchor: { kind: "storage-program", locator: `stor-${"c".repeat(40)}` }, + status: "active", + }; + const revocation = { + sellerPrimaryClaim: claim, + listingId, + listingVersion: 1, + listingContentHash: contentHash, + logicalAddress: `dacs1-revoked:${encodeURIComponent(claim)}:${listingId}:v1`, + markerAnchor: { kind: "storage-program", locator: `stor-${"d".repeat(40)}` }, + markerContentHash: "e".repeat(64), + }; + + assert.deepEqual(projectActiveListingIndexEntries([active], claim), { + ok: true, + listingAnchors: [active.anchor.locator], + contentHashes: { [active.anchor.locator]: contentHash }, + }); + assert.deepEqual(projectActiveListingIndexEntries([ + { ...active, status: "revoked", revocation }, + ], claim), { ok: true, listingAnchors: [], contentHashes: {} }); + + for (const invalid of [ + { ...active, revocation }, + { ...active, status: "revoked" }, + { ...active, status: "revoked", revocation: { ...revocation, listingContentHash: "f".repeat(64) } }, + { ...active, contentHash: contentHash.toUpperCase() }, + null, + ]) { + assert.equal(projectActiveListingIndexEntries([invalid], claim).ok, false); + } +}); + test("rate-limit state ignores spoofed proxy headers by default and stays bounded", () => { const prior = process.env.DACS_TRUST_PROXY; try { diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 91e0a49..7941bd7 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -35,7 +35,8 @@ import { } from "../src/catalog/scan.js"; import { deriveSellerReputation, flipOutcome } from "../src/catalog/reputation.js"; import type { Catalog, DealRecord, SellerRecord } from "../src/catalog/types.js"; -import type { BundleVerification } from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +import type { BundleVerification } from "@kynesyslabs/dacs"; +import type { LegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; const seed = Uint8Array.from(Buffer.alloc(32, 7)); const did = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; @@ -55,17 +56,12 @@ const listing = { supportedDelivery: ["deliver-attested-payload"], }; -test("listing verification requires a valid signer-bound envelope", async () => { +test("legacy listing reads do not invent a structured signature profile outside the SDK boundary", async () => { const message = Buffer.from(`dacs-listing:v1:${contentHash(listing)}`, "utf8"); const value = Buffer.from(await ed25519Sign(message, privateKeyFromSeed(seed))).toString("hex"); const signed = { ...listing, signature: { algorithm: "ed25519", signer: did, value } }; - assert.ok(await verifyListing(signed)); - assert.equal((await verifyListing({ - ...listing, - signature: { ...signed.signature, signer: `DID:demos:agent:${did.slice(-64)}` }, - }))?.signer, did, "scheme casing is canonicalized when comparing envelope and scope identities"); + assert.equal(await verifyListing(signed), null); assert.equal(await verifyListing({ ...signed, name: "tampered" }), null); - assert.equal(await verifyListing({ ...listing, signature: "deadbeef" }), null); assert.equal(await verifyListing({ ...signed, signatures: [null] }), null); assert.equal(ownerClaim(`0x${did.slice(-64)}`), did); }); @@ -106,7 +102,11 @@ test("listing verification accepts a current structured listing and verifies its description: "Description", category: "services.test", tags: ["test"], - deliverable: { kind: "attested-payload", payloadFormat: "application/json" }, + deliverable: { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ @@ -137,7 +137,14 @@ test("listing verification accepts a current structured listing and verifies its assert.equal(await verifyListing({ ...unsafeScope, signature: { algorithm: "ed25519", signer: did, value: unsafeValue } }), null); }); -function result(outcome: string, signatures: BundleVerification["signatures"]): BundleVerification { +type LegacyBundleVerification = Omit & { + bundle: LegacyMvpAttestationBundle; +}; + +function result( + outcome: string, + signatures: BundleVerification["signatures"], +): LegacyBundleVerification { return { ok: true, fullyVerified: signatures.every((s) => s.verdict === "valid"), @@ -372,10 +379,8 @@ test("strict ref policy binds positional kinds, hashes, and unique references", ...evidenceScope, signature: await signature("dacs-evidence:v1:", evidenceScope, did, seed), }; - const signedListing = { - ...listing, - signature: await signature("dacs-listing:v1:", listing, did, seed), - }; + const listingSignature = await signature("dacs-listing:v1:", listing, did, seed); + const signedListing = { ...listing, signature: listingSignature.value }; const verification = result("completed", [ { party: buyerDid, verdict: "valid" }, { party: did, verdict: "valid" }, @@ -410,7 +415,7 @@ test("strict ref policy binds positional kinds, hashes, and unique references", assert.equal(await refsPassStrictPolicy(verification, artifacts), true); const substituted = structuredClone(verification); - substituted.bundle!.agreementRef.kind = "dacs-2-verifyresult"; + substituted.bundle.agreementRef!.kind = "dacs-2-verifyresult"; substituted.refs[0].kind = "dacs-2-verifyresult"; assert.equal(await refsPassStrictPolicy(substituted, [ { ...artifacts[0], kind: "dacs-2-verifyresult" }, @@ -426,8 +431,8 @@ test("strict ref policy binds positional kinds, hashes, and unique references", artifacts[0], artifacts[1], artifacts[1], artifacts[2], ]), false); - const unsupported = structuredClone(verification) as BundleVerification & { - bundle: NonNullable & { ratingRefs: unknown[] }; + const unsupported = structuredClone(verification) as LegacyBundleVerification & { + bundle: LegacyMvpAttestationBundle & { ratingRefs: unknown[] }; }; unsupported.bundle.ratingRefs = [{ kind: "dacs-5-rating", id: "rating-j", contentHash: "f".repeat(64) }]; assert.equal(await refsPassStrictPolicy(unsupported, artifacts), false); @@ -545,7 +550,7 @@ test("any valid revocation candidate wins and scanner candidates deduplicate", a ).toString("hex"); const verified = await verifyListing({ ...listing, - signature: { algorithm: "ed25519", signer: did, value: listingSignature }, + signature: listingSignature, }); assert.ok(verified); if (!verified) return; @@ -695,7 +700,7 @@ test("listing verification is open-world: an unknown additive top-level field is identity: { ...identityScope, presentation: { kind: "per-claim", signatures: [{ ref: did, signature: identitySignature }] } }, displayName: "Service agent", publicEndpoint: "https://agent.example/a2a", }, - offering: { title: "Service", description: "Description", category: "services.test", tags: ["test"], deliverable: { kind: "attested-payload", payloadFormat: "application/json" } }, + offering: { title: "Service", description: "Description", category: "services.test", tags: ["test"], deliverable: { kind: "attested-payload", payloadFormat: "application/json", verificationMethod: { kind: "self-signed" } } }, buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, diff --git a/reference-implementations/dacs-directory/tsconfig.json b/reference-implementations/dacs-directory/tsconfig.json index e4e91f0..cf5d54a 100644 --- a/reference-implementations/dacs-directory/tsconfig.json +++ b/reference-implementations/dacs-directory/tsconfig.json @@ -23,6 +23,9 @@ "@/*": [ "./*" ], + "@kynesyslabs/dacs": [ + "./src/sdkVerification" + ], "@kynesyslabs/dacs/*": [ "./vendor/dacs-sdk/dist/*/index" ]