From 3ddb671fb9f1a420481bcd44db1a31a1d6685d45 Mon Sep 17 00:00:00 2001 From: random block Date: Sat, 15 Aug 2026 20:40:28 +0100 Subject: [PATCH 01/37] Align Directory listing admission with current SDK --- .../dacs-directory/README.md | 22 +- .../app/api/dacs/build-listing/route.ts | 8 +- .../app/api/dacs/derive/route.ts | 4 +- .../app/api/dacs/lookup/route.ts | 14 +- .../dacs-directory/next.config.mjs | 5 + .../dacs-directory/package-lock.json | 21 -- .../dacs-directory/scripts/setup-sdk.sh | 6 +- .../src/catalog/bundlePolicy.ts | 15 +- .../dacs-directory/src/catalog/contracts.ts | 2 +- .../dacs-directory/src/catalog/indexer.ts | 37 ++- .../src/catalog/legacySessionAnchorName.ts | 9 + .../src/catalog/listingVerification.ts | 298 +++++++++++------- .../dacs-directory/src/catalog/store.ts | 20 +- .../src/components/VerifyAttestation.tsx | 2 +- .../src/components/VerifyDeal.tsx | 14 +- .../dacs-directory/src/sdkVerification.ts | 15 + .../dacs-directory/src/shims/node-util.ts | 8 + .../dacs-directory/test/build-listing.test.ts | 8 + .../test/confirm-listing.test.ts | 6 +- .../test/current-indexer.test.ts | 2 +- .../test/dead-letter-diagnostics.test.ts | 15 + .../live-invalid-verification-methods.json | 20 ++ .../test/listing-verification.test.ts | 77 ++++- .../dacs-directory/test/verification.test.ts | 43 +-- .../dacs-directory/tsconfig.json | 3 + 25 files changed, 457 insertions(+), 217 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts create mode 100644 reference-implementations/dacs-directory/src/sdkVerification.ts create mode 100644 reference-implementations/dacs-directory/src/shims/node-util.ts create mode 100644 reference-implementations/dacs-directory/test/fixtures/live-invalid-verification-methods.json diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..2e509a4 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -6,7 +6,8 @@ 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 admission. 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 @@ -175,6 +176,13 @@ 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 exposes `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. 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. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, @@ -211,8 +219,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) @@ -247,8 +258,9 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). `exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile 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 Listing and component-signature APIs; 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..d9335f6 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 @@ -203,7 +203,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" @@ -277,7 +281,7 @@ export async function POST(req: NextRequest) { // 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 { const nonce = await accountNonce(hex); 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/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/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index ef9bdeb..b8fa425 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="2d53f03778189b8f36573720e68d8743a94e4f2b" # 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/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 4b6d057..ae1e64d 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -190,7 +190,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/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index d3995a4..483a2fd 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -18,14 +18,11 @@ 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 { canonicalDemosAgentClaim } from "./claimRef.js"; import { resolveDemosPrimaryClaimKey } from "./primaryClaimKey.js"; import { listingPresentation } from "./listingMetadata.js"; @@ -48,6 +45,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, @@ -121,7 +119,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 +137,12 @@ 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); + continue; + } + const verified = verification.value; const { scope } = verified; const bindingRejection = listingBindingRejection( verified.sellerClaim, @@ -151,9 +153,12 @@ export async function indexRegistration( recordListingRejection(anchor, reg.primaryClaim, bindingRejection); 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"); + continue; + } + clearListingRejection(anchor, reg.primaryClaim); const listingId = typeof scope.listingId === "string" ? scope.listingId : typeof scope.serviceId === "string" ? scope.serviceId : ""; if (!listingId) continue; @@ -369,9 +374,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 +387,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/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/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/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index faa4ea8..7dd431e 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -386,7 +386,15 @@ 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 type ListingRejectionCode = + | "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"; export function recordListingRejection( locator: string, @@ -484,7 +492,7 @@ export interface IndexerDiagnostics { items: PublicDeadLetterDiagnostic[]; }; listingRejectionDiagnostics: { - scope: "listing-registration-binding"; + scope: "listing-admission"; total: number; byCode: Record; query: { locator: string | null; limit: number }; @@ -511,6 +519,12 @@ interface ListingRejectionRow { 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.", + 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.", }; const publicFailure = (code: string): { code: string; message: string } => @@ -595,7 +609,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/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/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..218ed9e 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -96,6 +96,14 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind minTotal: { amount: "1", currency: "USD" }, }); assert.deepEqual(built.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); + assert.deepEqual( + (built.listing.offering as Record).deliverable, + { + kind: "attested-payload", + payloadFormat: "application/json", + verificationMethod: { kind: "self-signed" }, + }, + ); assert.deepEqual(built.listing.pipeline, [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 37882e2..06eb966 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -40,7 +40,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: [ diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 28d7574..3acf812 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -38,7 +38,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 }, 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..9c80139 100644 --- a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts +++ b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts @@ -115,6 +115,7 @@ test("listing binding rejections are persistent, public-safe, filterable, and re store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); 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); @@ -132,6 +133,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/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/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/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" ] From fd7a8e7f8659bae1e04d23ec788bf3a73693cbb4 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 10:32:04 +0100 Subject: [PATCH 02/37] Complete Directory SDK contract alignment --- .../app/api/dacs/wellknown-files/route.ts | 1 + .../dacs-directory/scripts/setup-sdk.sh | 2 +- .../dacs-directory/src/catalog/contracts.ts | 11 ++- .../dacs-directory/src/catalog/indexer.ts | 23 ++++- .../src/catalog/listingAdmission.ts | 24 +++++ .../dacs-directory/src/catalog/reindexCore.ts | 4 +- .../dacs-directory/src/catalog/store.ts | 53 +++++----- .../dacs-directory/src/catalog/types.ts | 2 +- .../dacs-directory/src/catalog/wellknown.ts | 99 ++++++++++++++----- .../test/current-indexer.test.ts | 15 ++- .../test/discovery-contract.test.ts | 15 ++- .../test/listing-summary-migration.test.ts | 67 +++++++++++++ .../dacs-directory/test/security.test.ts | 41 ++++++++ 13 files changed, 290 insertions(+), 67 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/catalog/listingAdmission.ts create mode 100644 reference-implementations/dacs-directory/test/listing-summary-migration.test.ts 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/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index b8fa425..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="2d53f03778189b8f36573720e68d8743a94e4f2b" +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 diff --git a/reference-implementations/dacs-directory/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index ae1e64d..b71976b 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; @@ -49,7 +50,7 @@ export const listingSummarySchema = { }, }, status: { enum: ["active", "revoked"] }, - revocationBinding: { + revocation: { type: "object", required: [ "sellerPrimaryClaim", "listingId", "listingVersion", "listingContentHash", @@ -84,8 +85,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 +113,8 @@ 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." }, + 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 }, diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 483a2fd..066ba98 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -88,6 +88,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. * @@ -166,13 +180,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, @@ -210,8 +223,8 @@ 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, }); listingArtifacts.set(`${listingId}\n${version}\n${verified.contentHash}`, { locator: anchor, raw: anchored.data }); 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..4e13ea4 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts @@ -0,0 +1,24 @@ +/** 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 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.", + 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/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 { setJson("sr2-anchor-schema-version", 2); })(); +// 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); +})(); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** Cross-process/instance lease. SQLite serializes acquisition; expired leases recover automatically. */ @@ -386,16 +416,6 @@ 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" - | "NORMATIVE_LISTING_INVALID" - | "VERIFICATION_METHOD_INVALID" - | "LISTING_SIGNATURE_INVALID" - | "IDENTITY_PRESENTATION_INVALID" - | "LEGACY_LISTING_INVALID" - | "DECLARED_CONTENT_HASH_MISMATCH"; - export function recordListingRejection( locator: string, registrationClaim: string, @@ -516,17 +536,6 @@ interface ListingRejectionRow { 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.", - 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.", -}; - const publicFailure = (code: string): { code: string; message: string } => PUBLIC_FAILURES[code] ? { code, message: PUBLIC_FAILURES[code] } diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 81cce82..8e28e24 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -118,7 +118,7 @@ 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; 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/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 3acf812..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"; @@ -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/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index 2b355b6..b849538 100644 --- a/reference-implementations/dacs-directory/test/discovery-contract.test.ts +++ b/reference-implementations/dacs-directory/test/discovery-contract.test.ts @@ -92,9 +92,9 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { 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 +116,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/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/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 { From 026fad0f02fd5627e6bd16a1444403d7d5b5b261 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 11:30:22 +0100 Subject: [PATCH 03/37] Harden Directory listing publication and admission --- .../dacs-directory/README.md | 48 +++- .../app/api/dacs/build-listing/route.ts | 112 +++++++++- .../app/api/dacs/confirm-listing/route.ts | 47 +++- .../api/dacs/prepare-registration/route.ts | 2 +- .../dacs-directory/app/globals.css | 11 + .../dacs-directory/app/how-it-works/page.tsx | 5 +- .../dacs-directory/app/llms.txt/route.ts | 4 +- .../dacs-directory/app/register/page.tsx | 157 +++++++++++--- .../app/seller/[claim]/page.tsx | 2 +- .../[seller]/[listingId]/[version]/page.tsx | 9 +- .../dacs-directory/e2e/register.spec.ts | 41 +++- .../dacs-directory/package.json | 1 + .../scripts/check-live-listing-upgrade.ts | 185 ++++++++++++++++ .../dacs-directory/src/catalog/chain.ts | 37 +++- .../dacs-directory/src/catalog/contracts.ts | 14 +- .../src/catalog/counterpartyEvidence.ts | 2 + .../dacs-directory/src/catalog/indexer.ts | 24 +- .../dacs-directory/src/catalog/inspection.ts | 11 +- .../src/catalog/listingAdmission.ts | 23 +- .../src/catalog/listingFinality.ts | 205 ++++++++++++++++++ .../src/catalog/listingOptions.ts | 14 +- .../dacs-directory/src/catalog/store.ts | 57 ++++- .../dacs-directory/src/catalog/types.ts | 11 + .../src/components/CatalogStatus.tsx | 52 ++++- .../dacs-directory/src/components/TryDacs.tsx | 2 +- .../listing-publication-recovery.ts | 9 + .../src/sdkDemosWriteEvidence.ts | 13 ++ .../src/sdkListingValidation.ts | 12 + .../dacs-directory/test/build-listing.test.ts | 98 ++++++--- .../test/confirm-listing.test.ts | 92 +++++++- .../test/dead-letter-diagnostics.test.ts | 12 +- .../test/discovery-contract.test.ts | 2 + .../dacs-directory/test/inspection.test.ts | 3 + 33 files changed, 1172 insertions(+), 145 deletions(-) create mode 100644 reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts create mode 100644 reference-implementations/dacs-directory/src/catalog/listingFinality.ts create mode 100644 reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts create mode 100644 reference-implementations/dacs-directory/src/sdkListingValidation.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 2e509a4..519f1ca 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -7,7 +7,9 @@ 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 explicit legacy read profile through program-name and content-shape detection. Current -listings must pass the pinned SDK's normative `isListing()` gate before admission. Registration adds a display +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 @@ -21,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 | @@ -176,13 +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 exposes `listingRejectionDiagnostics` with scope +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. For example, string-valued deliverable verification methods +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, Demos RPC, and gateway. The replacement manifest must map +all three known old listing IDs to their new anchors/versions, the independent verifier's +supported payload-method kinds, and the corresponding executable gateway profile IDs: + +```bash +NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \ +NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.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, and `/try` can resolve an executable gateway profile. It never +writes, reindexes, purchases, or invokes an agent. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, @@ -235,10 +256,16 @@ shim over JSON-owned values, and 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 @@ -256,9 +283,10 @@ shim over JSON-owned values, and 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 -pass the pinned SDK's normative Listing and component-signature APIs; current-contract +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, 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 d9335f6..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 && ( @@ -242,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( @@ -274,7 +343,7 @@ 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 }, ); } @@ -284,6 +353,23 @@ export async function POST(req: NextRequest) { 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); @@ -292,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", @@ -314,6 +404,7 @@ export async function POST(req: NextRequest) { status: "", blockNumber: null, }; + publishedListing = signedCandidate; } const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did); @@ -346,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/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/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/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..126b963 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -0,0 +1,185 @@ +/** + * 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://... + * NEXT_PUBLIC_BUTLER_ORIGIN=https://... + * DACS_LISTING_UPGRADE_REPLACEMENTS='[{"listingId":"...","replacementLocator":"stor-...","replacementVersion":4,"gatewayProfileId":"...","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; + gatewayProfileId: string; + 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`); + } + for (const item of parsed) { + if ( + !item || typeof item !== "object" || Array.isArray(item) || + typeof item.listingId !== "string" || + typeof item.replacementLocator !== "string" || !/^stor-[0-9a-f]{40}$/.test(item.replacementLocator) || + !Number.isSafeInteger(item.replacementVersion) || Number(item.replacementVersion) < 1 || + typeof item.gatewayProfileId !== "string" || !item.gatewayProfileId || + !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 gateway = httpsOrigin("NEXT_PUBLIC_BUTLER_ORIGIN"); + const manifest = replacements(); + const gatewayCatalog = await json(`${gateway}/demo/butler/agents`); + const gatewayProfiles = Array.isArray(gatewayCatalog.procurementProfiles) + ? gatewayCatalog.procurementProfiles + : []; + + 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})`); + } + + const gatewayProfile = gatewayProfiles.find((profile) => profile && typeof profile === "object" && !Array.isArray(profile) && + profile.id === replacement.gatewayProfileId) as Record | undefined; + if (!gatewayProfile || gatewayProfile.executable !== true) { + throw new Error(`${replacement.listingId} gateway profile ${replacement.gatewayProfileId} is not executable`); + } + process.stdout.write(`ok ${old.listingId}@${old.listingVersion} rejected -> v${replacement.replacementVersion} SDK-verified and discoverable\n`); + } + + const tryPage = await fetch(`${directory}/try`, { signal: AbortSignal.timeout(15_000) }); + if (!tryPage.ok) throw new Error(`/try returned HTTP ${tryPage.status}`); + process.stdout.write("ok /try resolves and every replacement maps to an executable gateway profile\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/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 b71976b..719c5ec 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -8,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 }, @@ -71,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", @@ -113,6 +123,8 @@ export const listingRejectionDiagnosticSchema = { required: ["locator", "code", "message", "occurrences", "firstSeenAt", "lastSeenAt"], properties: { locator: { type: "string", pattern: "^stor-[0-9a-f]{40}$" }, + 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 }, 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 066ba98..c07e5a1 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -23,6 +23,8 @@ import { verifyBundleCore } from "@kynesyslabs/dacs"; import { deriveAnchorAddress, readAnchor, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.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"; @@ -153,7 +155,12 @@ export async function indexRegistration( if (!anchored) continue; const verification = await verifyListingResult(anchored.data); if (!verification.ok) { - recordListingRejection(anchor, reg.primaryClaim, verification.code); + recordListingRejection( + anchor, + reg.primaryClaim, + verification.code, + listingDiagnosticCoordinates(anchored.data), + ); continue; } const verified = verification.value; @@ -164,12 +171,22 @@ export async function indexRegistration( reg.primaryClaim, ); if (bindingRejection) { - recordListingRejection(anchor, reg.primaryClaim, bindingRejection); + recordListingRejection( + anchor, + reg.primaryClaim, + bindingRejection, + listingDiagnosticCoordinates(verified.scope), + ); continue; } const declaredHash = reg.listingContentHashes?.[anchor]?.replace(/^sha256-/, "").toLowerCase(); if (declaredHash && declaredHash !== verified.contentHash) { - recordListingRejection(anchor, reg.primaryClaim, "DECLARED_CONTENT_HASH_MISMATCH"); + recordListingRejection( + anchor, + reg.primaryClaim, + "DECLARED_CONTENT_HASH_MISMATCH", + listingDiagnosticCoordinates(verified.scope), + ); continue; } clearListingRejection(anchor, reg.primaryClaim); @@ -226,6 +243,7 @@ export async function indexRegistration( 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 }); } 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/listingAdmission.ts b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts index 4e13ea4..db27a8d 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts @@ -12,8 +12,29 @@ export const LISTING_REJECTION_CODES = [ 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 verified listing seller does not match the registration claim.", + 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.", 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/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index abb8b9d..1af6c31 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -6,8 +6,10 @@ import { randomUUID } from "node:crypto"; import type { Catalog, ListingSummary, Registration, RevocationBinding, ScanState } from "./types.js"; import { deriveSellerReputation } from "./reputation.js"; +import { DIRECTORY_TRANSACTION_READINESS } from "./inspection.js"; import { LISTING_REJECTION_MESSAGES, + type ListingDiagnosticCoordinates, type ListingRejectionCode, } from "./listingAdmission.js"; @@ -120,6 +122,8 @@ db.exec(` locator TEXT NOT NULL, registration_claim TEXT NOT NULL, reason_code TEXT NOT NULL, + listing_id TEXT, + listing_version INTEGER, occurrences INTEGER NOT NULL DEFAULT 1, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, @@ -129,6 +133,16 @@ db.exec(` ON listing_rejections(last_seen_at DESC, locator); `); +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"); +} + const readLegacy = (path: string, fallback: T): T => existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as T : fallback; @@ -201,6 +215,24 @@ if (getJson("listing-summary-revocation-schema-version", 0) < 1) db.transaction( setJson("listing-summary-revocation-schema-version", 1); })(); +// 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); +})(); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** Cross-process/instance lease. SQLite serializes acquisition; expired leases recover automatically. */ @@ -420,16 +452,27 @@ 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 { @@ -472,6 +515,8 @@ export interface PublicDeadLetterDiagnostic { export interface PublicListingRejectionDiagnostic { locator: string; + listingId?: string; + listingVersion?: number; code: ListingRejectionCode; message: string; occurrences: number; @@ -530,6 +575,8 @@ interface DeadLetterRow { interface ListingRejectionRow { locator: string; + listing_id: string | null; + listing_version: number | null; reason_code: ListingRejectionCode; occurrences: number; first_seen_at: number; @@ -594,7 +641,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 @@ -603,6 +650,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, diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 8e28e24..2f5fa9a 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -122,11 +122,22 @@ export interface ListingSummary { 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/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/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/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 218ed9e..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,55 +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(preview.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); assert.deepEqual( - (built.listing.offering as Record).deliverable, - { - kind: "attested-payload", - payloadFormat: "application/json", - verificationMethod: { kind: "self-signed" }, - }, + (preview.listing.offering as Record).deliverable, + { kind: "storage-program", accessModel: "public" }, ); - assert.deepEqual(built.listing.pipeline, [ + 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; } @@ -152,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; @@ -161,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") { @@ -217,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 06eb966..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, @@ -67,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, @@ -82,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/dead-letter-diagnostics.test.ts b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts index 9c80139..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,8 +111,14 @@ 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"); @@ -121,6 +127,8 @@ test("listing binding rejections are persistent, public-safe, filterable, and re 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, diff --git a/reference-implementations/dacs-directory/test/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index b849538..6edbad9 100644 --- a/reference-implementations/dacs-directory/test/discovery-contract.test.ts +++ b/reference-implementations/dacs-directory/test/discovery-contract.test.ts @@ -89,6 +89,8 @@ 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"); 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); }); From 1d24aea1740278a98f59e57807a5ba4f19743b74 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 11:34:56 +0100 Subject: [PATCH 04/37] Serialize concurrent SQLite migrations --- .../dacs-directory/src/catalog/store.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index 1af6c31..cfb0e38 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -133,15 +133,20 @@ db.exec(` ON listing_rejections(last_seen_at DESC, locator); `); -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"); -} +// BEGIN IMMEDIATE serializes the read-then-DDL upgrade across app/indexer +// processes. A deferred transaction can let both readers observe the old +// schema and then fail one upgrader immediately with SQLITE_BUSY. +db.transaction(() => { + 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; @@ -156,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, [])); @@ -164,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 @@ -189,7 +196,7 @@ 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 @@ -213,7 +220,7 @@ if (getJson("listing-summary-revocation-schema-version", 0) < 1) db.transaction( })), }); 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 @@ -231,7 +238,7 @@ if (getJson("listing-summary-readiness-schema-version", 0) < 1) db.transaction(( })), }); setJson("listing-summary-readiness-schema-version", 1); -})(); +}).immediate(); const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); From b2309dbe6d84ccc5b5a21a1badd72d9e4cb83c71 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 14:42:58 +0100 Subject: [PATCH 05/37] Decouple listing acceptance from the demo gateway --- .../dacs-directory/README.md | 12 +++++----- .../scripts/check-live-listing-upgrade.ts | 22 ++++--------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 519f1ca..d810e87 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -187,13 +187,12 @@ are excluded as `VERIFICATION_METHOD_INVALID`; the Directory never aliases them registered structured verification-method variant. Before replacing malformed live Listings, run the read-only deployment acceptance check -against the deployed Directory, Demos RPC, and gateway. The replacement manifest must map -all three known old listing IDs to their new anchors/versions, the independent verifier's -supported payload-method kinds, and the corresponding executable gateway profile IDs: +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 \ -NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.example \ DACS_LISTING_UPGRADE_REPLACEMENTS='[...]' \ npm run check:listing-upgrade ``` @@ -201,8 +200,9 @@ 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, and `/try` can resolve an executable gateway profile. It never -writes, reindexes, purchases, or invokes an agent. +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 diff --git a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts index 126b963..2fbdcbd 100644 --- a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -4,8 +4,7 @@ * * Required: * NEXT_PUBLIC_DIRECTORY_URL=https://... - * NEXT_PUBLIC_BUTLER_ORIGIN=https://... - * DACS_LISTING_UPGRADE_REPLACEMENTS='[{"listingId":"...","replacementLocator":"stor-...","replacementVersion":4,"gatewayProfileId":"...","supportedPayloadMethods":["self-signed"]}, ...]' + * 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"; @@ -17,7 +16,6 @@ type Replacement = { listingId: string; replacementLocator: string; replacementVersion: number; - gatewayProfileId: string; supportedPayloadMethods: string[]; }; @@ -40,13 +38,14 @@ function replacements(): Replacement[] { 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 || - typeof item.gatewayProfileId !== "string" || !item.gatewayProfileId || !Array.isArray(item.supportedPayloadMethods) || !item.supportedPayloadMethods.every((kind: unknown) => typeof kind === "string") ) throw new Error("replacement manifest contains an invalid entry"); } @@ -85,12 +84,7 @@ const same = (left: unknown, right: unknown): boolean => { async function main() { const directory = httpsOrigin("NEXT_PUBLIC_DIRECTORY_URL"); - const gateway = httpsOrigin("NEXT_PUBLIC_BUTLER_ORIGIN"); const manifest = replacements(); - const gatewayCatalog = await json(`${gateway}/demo/butler/agents`); - const gatewayProfiles = Array.isArray(gatewayCatalog.procurementProfiles) - ? gatewayCatalog.procurementProfiles - : []; for (const old of invalidLiveListings) { const raw = await anchor(old.locator); @@ -166,17 +160,9 @@ async function main() { throw new Error(`${replacement.listingId} complete SDK reader returned ${validation.disposition} (${validation.reason})`); } - const gatewayProfile = gatewayProfiles.find((profile) => profile && typeof profile === "object" && !Array.isArray(profile) && - profile.id === replacement.gatewayProfileId) as Record | undefined; - if (!gatewayProfile || gatewayProfile.executable !== true) { - throw new Error(`${replacement.listingId} gateway profile ${replacement.gatewayProfileId} is not executable`); - } process.stdout.write(`ok ${old.listingId}@${old.listingVersion} rejected -> v${replacement.replacementVersion} SDK-verified and discoverable\n`); } - - const tryPage = await fetch(`${directory}/try`, { signal: AbortSignal.timeout(15_000) }); - if (!tryPage.ok) throw new Error(`/try returned HTTP ${tryPage.status}`); - process.stdout.write("ok /try resolves and every replacement maps to an executable gateway profile\n"); + process.stdout.write("ok every replacement is independently SDK-verified and discoverable\n"); } main().catch((error) => { From d0f5da15cb1945066c916c9ff5067004f17c3683 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:35:32 +0200 Subject: [PATCH 06/37] Fail closed on SDK-defined listing admission checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification method: admission already delegates to the pinned SDK's isListing (DACS-4 DPA-1) and isVerificationMethod (closed DACS-2 registry). Pin each failure mode with a test: missing, free-form string, unregistered kind, registered kind with missing or malformed parameters, attested phase on a non-attested deliverable, and an invalid method outside an attested phase. Every registered variant is admitted, and the indexer excludes failures with VERIFICATION_METHOD_INVALID diagnostics while admitting the conforming listing. Transaction readiness: the SDK grants it only through its ordered Listing reader (isVerifiedListingAdmission), which the Directory does not run. Catalog, seller and inspection projections now always publish the Directory's `unassessed` disposition instead of republishing a stored value. Anchor receipt: registration admits a listing only on a receipt that passes the SDK's CORE §5.1 isAnchorReceipt gate, is finalized and established, and binds the exact logical/native address, content hash, writer and transaction. Confirmation tests now also cover the finality-pending and metadata-mismatch paths. Co-Authored-By: Claude Opus 5.5 --- .../dacs/sellers/[primaryClaimRef]/route.ts | 3 +- .../dacs-directory/src/catalog/inspection.ts | 15 +- .../src/catalog/listingFinality.ts | 50 +++- .../test/confirm-listing.test.ts | 59 ++++- .../test/listing-admission.test.ts | 245 ++++++++++++++++++ 5 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/listing-admission.test.ts diff --git a/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts index 5b6b5f4..03251f2 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/sellers/[primaryClaimRef]/route.ts @@ -8,6 +8,7 @@ import { loadCatalog } from "@/src/catalog/store"; import { catalogJson } from "@/src/catalog/http"; import { requestBaseUrl } from "@/src/catalog/publicUrl"; import { canonicalDemosAgentClaim } from "@/src/catalog/claimRef"; +import { withDirectoryTransactionReadiness } from "@/src/catalog/inspection"; export async function GET( req: NextRequest, @@ -22,7 +23,7 @@ export async function GET( if (!seller) return NextResponse.json({ error: "seller not found" }, { status: 404 }); const origin = requestBaseUrl(req); return catalogJson(req, { - listings: seller.listings, + listings: seller.listings.map(withDirectoryTransactionReadiness), identity: seller.identityBundle ?? { profile: "legacy-sdk-v0.1", primaryClaim: seller.primaryClaim, diff --git a/reference-implementations/dacs-directory/src/catalog/inspection.ts b/reference-implementations/dacs-directory/src/catalog/inspection.ts index 3d3ba4c..a0b5001 100644 --- a/reference-implementations/dacs-directory/src/catalog/inspection.ts +++ b/reference-implementations/dacs-directory/src/catalog/inspection.ts @@ -92,10 +92,19 @@ export function directoryInspectionAffordance(listing: ListingSummary): Director }; } +/** + * The SDK establishes transaction readiness only through its ordered Listing + * reader (`isVerifiedListingAdmission`), which this Directory never runs. Every + * public projection carries the Directory's own disposition and never + * republishes a stored one. + */ +export function withDirectoryTransactionReadiness(listing: T): T { + return { ...listing, transactionReadiness: DIRECTORY_TRANSACTION_READINESS }; +} + export function withDirectoryInspectionAffordance(listing: T): T { return { - ...listing, - transactionReadiness: listing.transactionReadiness ?? DIRECTORY_TRANSACTION_READINESS, + ...withDirectoryTransactionReadiness(listing), inspection: directoryInspectionAffordance(listing), }; } @@ -119,7 +128,7 @@ export function buildDirectoryServiceProfile(origin: string, listing: ListingSum noLivePaymentClaim: true, 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, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, service: { title: listing.offering.title, category: listing.offering.category, diff --git a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts index 1041f98..01971d9 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -5,7 +5,7 @@ import { demosWriteEvidenceToAnchorReceipt, type DemosWriteEvidence, } from "../sdkDemosWriteEvidence.js"; -import type { AnchorReceipt } from "@kynesyslabs/dacs/artifacts"; +import { isAnchorReceipt, type AnchorReceipt } from "@kynesyslabs/dacs/artifacts"; import { readAnchorRecord } from "./chain.js"; import { storageWriteCandidate } from "./scan.js"; @@ -96,6 +96,37 @@ export type FinalizedListingAnchorResult = | { status: "rejected"; reason: string } | { status: "finalized"; receipt: AnchorReceipt }; +export interface ExpectedListingAnchorWrite { + anchorAddress: string; + logicalAddress: string; + listingContentHash: string; + sellerClaim: string; + transactionRef: string; +} + +/** + * Registration admits a Listing only on a receipt that passes the SDK's CORE + * §5.1 AnchorReceipt gate, records an established finalized write, and binds + * this exact listing write. Returns the rejection reason, or null to admit. + */ +export function listingAnchorReceiptRejection( + receipt: unknown, + expected: ExpectedListingAnchorWrite, +): string | null { + if (!isAnchorReceipt(receipt)) return "anchor receipt is not a CORE §5.1 AnchorReceipt"; + if (receipt.state !== "finalized" || receipt.observationDisposition !== "established") { + return "anchor receipt does not establish a finalized write"; + } + if ( + receipt.logicalAddress !== expected.logicalAddress || + receipt.nativeAddress !== expected.anchorAddress || + receipt.contentHash !== expected.listingContentHash || + receipt.writer !== expected.sellerClaim || + receipt.transactionRef.value !== expected.transactionRef + ) return "anchor receipt does not bind this listing write"; + return null; +} + /** * Establish a portable finalized Demos AnchorReceipt from canonical transaction, * BFT-confirmed block, and exact native readback. Read visibility alone never @@ -183,15 +214,14 @@ export async function finalizedListingAnchorReceipt(input: { 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, - }), - }; + const receipt = demosWriteEvidenceToAnchorReceipt({ + logicalAddress: input.logicalAddress, + contentHash: input.listingContentHash, + writer: input.sellerClaim, + evidence, + }); + const rejection = listingAnchorReceiptRejection(receipt, { ...input, transactionRef: txRef }); + return rejection ? { status: "rejected", reason: rejection } : { status: "finalized", receipt }; } catch (error) { return { status: "pending", diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 0baf476..048c0cb 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { NextRequest } from "next/server"; +import { isAnchorReceipt } from "@kynesyslabs/dacs/artifacts"; import { canonicalize, contentHash, listingAddress, sha256Hex } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; @@ -116,9 +117,9 @@ const request = (body: Record) => new NextRequest( { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, ); -test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (input, init) => { +/** Stub the Demos node: native readback, canonical write, and its block. */ +function stubChain(overrides: { nativeMetadata?: Record; block?: Record } = {}): void { + globalThis.fetch = async (_input, init) => { if (init?.method !== "POST") { return Response.json({ success: true, @@ -126,7 +127,7 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has owner, programName, storageAddress: anchorAddress, - metadata, + metadata: overrides.nativeMetadata ?? metadata, createdByTx: transactionHash, interactionTxs: [], }); @@ -143,6 +144,7 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has hash: "block-5", content: { timestamp: 1_786_360_000, ordered_transactions: [transactionHash] }, validation_data: { validators: ["fixture-validator"], quorum: 1 }, + ...overrides.block, }, }); } @@ -151,6 +153,11 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has } return Response.json({ result: 500, response: null }); }; +} + +test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { + const originalFetch = globalThis.fetch; + stubChain(); try { const response = await POST(request(coordinates)); assert.equal(response.status, 200); @@ -162,7 +169,49 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has assert.equal(body.sellerClaim, sellerClaim); assert.equal(body.listingId, scope.listingId); assert.equal(body.listingVersion, 1); - assert.equal((body.anchorReceipt as Record).state, "finalized"); + const receipt = body.anchorReceipt as Record; + assert.equal(isAnchorReceipt(receipt), true); + assert.equal(receipt.state, "finalized"); + assert.equal(receipt.observationDisposition, "established"); + assert.equal(receipt.logicalAddress, logicalAddress); + assert.equal(receipt.nativeAddress, anchorAddress); + assert.equal(receipt.contentHash, listingHash); + assert.deepEqual(receipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation never registers before the write is BFT-final", async () => { + const originalFetch = globalThis.fetch; + try { + for (const block of [ + { status: "pending" }, + { content: { timestamp: 1_786_360_000, ordered_transactions: [] } }, + { validation_data: undefined }, + ]) { + stubChain({ block }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 202, JSON.stringify(block)); + const body = await response.json() as Record; + assert.equal(body.confirmed, false); + assert.equal(body.state, "finality-pending"); + assert.equal(body.anchorReceipt, undefined); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation rejects native metadata that does not bind the signed artifact", async () => { + const originalFetch = globalThis.fetch; + try { + stubChain({ nativeMetadata: { ...metadata, envelopeHash: "0".repeat(64) } }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 409); + const body = await response.json() as Record; + assert.equal(body.state, "finality-failed"); + assert.equal(body.anchorReceipt, undefined); } finally { globalThis.fetch = originalFetch; } diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts new file mode 100644 index 0000000..00696ff --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; + +import { isAnchorReceipt, isListing } from "@kynesyslabs/dacs/artifacts"; +import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import type { ListingSummary, SellerRecord } from "../src/catalog/types.js"; + +// Directory listing admission against what the pinned SDK defines for a +// Listing: DPA-1 verification methods, transaction readiness (only the SDK's +// ordered reader grants it), and CORE §5.1 anchor receipts. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-listing-admission-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const store = await import("../src/catalog/store.js"); +const { verifyListingResult } = await import("../src/catalog/listingVerification.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { DIRECTORY_TRANSACTION_READINESS, buildDirectoryServiceProfile } = await import("../src/catalog/inspection.js"); +const { listingAnchorReceiptRejection } = await import("../src/catalog/listingFinality.js"); +const { GET: listingsGet } = await import("../app/api/dacs/listings/route.js"); +const { GET: sellerGet } = await import("../app/api/dacs/sellers/[primaryClaimRef]/route.js"); + +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +type Obj = Record; +const seed = Uint8Array.from(Buffer.alloc(32, 41)); +const privateKey = privateKeyFromSeed(seed); +const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex"); +const seller = `did:demos:agent:${keyHex}`; +const sign = (message: string) => Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); + +const ATTESTED_PIPELINE = [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-dem", parameters: { rail: "pay-dem" } }, + { kind: "deliver-attested-payload" }, +]; +const attested = (verificationMethod?: unknown): Obj => ({ + kind: "attested-payload", + payloadFormat: "application/json", + ...(verificationMethod === undefined ? {} : { verificationMethod }), +}); + +/** A correctly signed current Listing; only the supplied fields vary. */ +function signedListing(listingId: string, deliverable: Obj, extra: Obj = {}): Obj { + const identity: Obj = { bundleVersion: "1", presentedBy: seller, presentedAt: 1, claims: [{ ref: seller }] }; + identity.presentation = { + kind: "per-claim", + signatures: [{ ref: seller, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`) }], + }; + const scope: Obj = { + dacsVersion: "1", + listingVersion: 1, + listingId, + seller: { identity, displayName: "Admission Desk" }, + offering: { title: listingId, description: "Admission fixture.", category: "services.test", tags: [], deliverable }, + buyerRequirement: { requirementVersion: "1", required: [] }, + pipeline: ATTESTED_PIPELINE, + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "pay-dem" }], + terms: {}, + validity: { notBefore: 1 }, + ...extra, + }; + return { ...scope, signature: { algorithm: "ed25519", signer: seller, value: sign(`dacs-listing:v1:${contentHash(scope)}`) } }; +} + +// DACS-2 §7.4.1: the SDK's closed registry, one valid instance per variant. +const REGISTERED_METHODS: Obj[] = [ + { kind: "verifiable-credential" }, + { kind: "tlsnotary", endpoint: "https://notary.example/session" }, + { kind: "zktls", provider: "reclaim", programId: "program-1" }, + { kind: "consensus-backed-proxy", endpoint: { method: "GET", urlTemplate: "https://api.example/{jobId}" } }, + { kind: "oauth-attested", provider: "github", scopes: ["read:user"], maxTokenAgeSec: 300 }, + { kind: "evm-rpc", chainId: 1, contract: `0x${"ab".repeat(20)}`, method: "balanceOf" }, + { kind: "domain-tls-control", challengeType: "dns-01" }, + { kind: "self-signed" }, + { kind: "demos-gcr-domain" }, +]; + +test("admission accepts every registered structured verification method", async () => { + for (const method of REGISTERED_METHODS) { + const listing = signedListing("registered-method", attested(method)); + assert.equal(isListing(listing), true, String(method.kind)); + const result = await verifyListingResult(listing); + assert.equal(result.ok, true, String(method.kind)); + } +}); + +test("admission rejects each DPA-1 verification-method failure, even when signed", async () => { + const rejected: Record = { + "missing on an attested-payload pipeline": signedListing("missing-method", attested()), + "free-form string": signedListing("string-method", attested("x402-receipt")), + "unregistered structured kind": signedListing("unregistered-method", attested({ kind: "llm-judge" })), + "registered kind without its parameters": signedListing("tlsnotary-no-endpoint", attested({ kind: "tlsnotary" })), + "registered kind with malformed parameters": signedListing("evm-rpc-bad-contract", attested({ + kind: "evm-rpc", chainId: 1, contract: "0xnot-an-address", method: "balanceOf", + })), + "attested phase on a non-attested deliverable": signedListing("storage-deliverable", { kind: "storage-program" }), + "invalid method outside an attested phase": signedListing("external-method", { + kind: "external", description: "Manual delivery", verificationMethod: { kind: "llm-judge" }, + }, { pipeline: [...ATTESTED_PIPELINE.slice(0, 3), { kind: "deliver-storage-program" }] }), + }; + for (const [label, listing] of Object.entries(rejected)) { + assert.deepEqual(await verifyListingResult(listing), { ok: false, code: "VERIFICATION_METHOD_INVALID" }, label); + } + // Other normative failures keep their own class rather than becoming method failures. + assert.deepEqual( + await verifyListingResult(signedListing("bad-pricing", attested({ kind: "self-signed" }), { pricing: { kind: "barter" } })), + { ok: false, code: "NORMATIVE_LISTING_INVALID" }, + ); +}); + +test("the indexer excludes verification-method failures with diagnostics and admits the conforming listing", async () => { + const anchors = { + [`stor-${"1".repeat(40)}`]: signedListing("string-method", attested("x402-receipt")), + [`stor-${"2".repeat(40)}`]: signedListing("missing-method", attested()), + [`stor-${"3".repeat(40)}`]: signedListing("structured-method", attested({ kind: "self-signed" })), + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = anchors[String(input).split("/").pop() ?? ""]; + return data + ? Response.json({ success: true, owner: `0x${keyHex}`, programName: "opaque", data }) + : new Response(null, { status: 404 }); + }; + try { + const record = await indexRegistration( + { primaryClaim: seller, displayName: "Admission Desk", listingAnchors: Object.keys(anchors) }, + undefined, + async () => { throw new Error("identity unavailable"); }, + ); + assert.deepEqual(record.listings.map((listing) => listing.listingId), ["structured-method"]); + assert.equal(record.listings[0].status, "active"); + assert.deepEqual(record.listings[0].transactionReadiness, DIRECTORY_TRANSACTION_READINESS); + + const diagnostics = store.indexerDiagnostics().listingRejectionDiagnostics; + assert.equal(diagnostics.byCode.VERIFICATION_METHOD_INVALID, 2); + assert.deepEqual( + diagnostics.items.map(({ locator, listingId, listingVersion, code }) => ({ locator, listingId, listingVersion, code })) + .sort((left, right) => left.locator.localeCompare(right.locator)), + [ + { locator: `stor-${"1".repeat(40)}`, listingId: "string-method", listingVersion: 1, code: "VERIFICATION_METHOD_INVALID" }, + { locator: `stor-${"2".repeat(40)}`, listingId: "missing-method", listingVersion: 1, code: "VERIFICATION_METHOD_INVALID" }, + ], + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("published summaries never claim transaction readiness the Directory did not assess", async () => { + const summary = (listingId: string, transactionReadiness?: unknown): ListingSummary => ({ + listingId, + version: 1, + contentHash: "c".repeat(64), + anchor: { kind: "storage-program", locator: `stor-${"4".repeat(40)}` }, + seller: { primaryClaim: seller, displayName: "Admission Desk" }, + offering: { title: listingId, category: "services.test", tags: [] }, + pricing: {}, + status: "active", + catalogObservedAt: 1, + ...(transactionReadiness === undefined ? {} : { transactionReadiness }), + } as ListingSummary); + const forged = summary("forged-ready", { disposition: "ready", reason: "stored by an older or hostile writer" }); + const record: SellerRecord = { + primaryClaim: seller, + displayName: "Admission Desk", + cci: [], + listings: [forged, summary("readiness-absent")], + deals: [], + reputation: { completed: 0, bundleCount: 0, totalAgreements: 0, completionRate: null }, + registeredAt: 1, + lastIndexedAt: 1, + } as SellerRecord; + store.saveCatalog({ catalogVersion: "1", generatedAt: 2, sellers: [record] }); + + const catalog = await (await listingsGet(new NextRequest("https://directory.example/api/dacs/listings"))).json(); + const sellerView = await (await sellerGet( + new NextRequest(`https://directory.example/api/dacs/sellers/${encodeURIComponent(seller)}`), + { params: Promise.resolve({ primaryClaimRef: encodeURIComponent(seller) }) }, + )).json(); + for (const published of [...catalog.listings, ...sellerView.listings]) { + assert.deepEqual(published.transactionReadiness, DIRECTORY_TRANSACTION_READINESS, published.listingId); + } + assert.equal(catalog.listings.length, 2); + assert.equal(sellerView.listings.length, 2); + assert.deepEqual( + buildDirectoryServiceProfile("https://directory.example", forged).transactionReadiness, + DIRECTORY_TRANSACTION_READINESS, + ); +}); + +test("registration admits only an SDK-shaped, established, finalized receipt for this exact write", () => { + const listingContentHash = "d".repeat(64); + const expected = { + anchorAddress: `stor-${"5".repeat(40)}`, + logicalAddress: listingAddress(seller, "receipt-listing", 1), + listingContentHash, + sellerClaim: seller, + transactionRef: "e".repeat(64), + }; + const receipt: Obj = { + receiptVersion: "1", + substrate: "demos", + finalityProfile: "demos-bft-confirmed-native-read", + logicalAddress: expected.logicalAddress, + nativeAddress: expected.anchorAddress, + contentHash: listingContentHash, + transactionRef: { kind: "demos-storage-program", value: expected.transactionRef }, + writer: seller, + nonce: "7", + state: "finalized", + observationDisposition: "established", + observedAt: 1_786_360_000_000, + blockRef: { id: "block-5", height: "5", timestamp: 1_786_360_000_000 }, + evidence: { kind: "demos-bft-write-proof-v1", value: "proof" }, + }; + assert.equal(isAnchorReceipt(receipt), true); + assert.equal(listingAnchorReceiptRejection(receipt, expected), null); + + const { blockRef: _blockRef, ...withoutBlock } = receipt; + const { evidence: _evidence, ...withoutEvidence } = receipt; + const rejections: Record = { + "missing evidence": [withoutEvidence, /not a CORE §5\.1 AnchorReceipt/], + "unknown member": [{ ...receipt, trustLevel: "high" }, /not a CORE §5\.1 AnchorReceipt/], + "finalized without a block": [withoutBlock, /not a CORE §5\.1 AnchorReceipt/], + "included, not finalized": [{ ...receipt, state: "included" }, /does not establish a finalized write/], + "indeterminate observation": [ + { ...receipt, observationDisposition: "indeterminate", preservedReceiptHash: "f".repeat(64) }, + /does not establish a finalized write/, + ], + "another logical address": [{ ...receipt, logicalAddress: listingAddress(seller, "receipt-listing", 2) }, /does not bind/], + "another native address": [{ ...receipt, nativeAddress: `stor-${"6".repeat(40)}` }, /does not bind/], + "another content hash": [{ ...receipt, contentHash: "0".repeat(64) }, /does not bind/], + "another writer": [{ ...receipt, writer: `did:demos:agent:${"7".repeat(64)}` }, /does not bind/], + "another transaction": [{ ...receipt, transactionRef: { kind: "demos-storage-program", value: "1".repeat(64) } }, /does not bind/], + }; + for (const [label, [candidate, reason]] of Object.entries(rejections)) { + assert.match(listingAnchorReceiptRejection(candidate, expected) ?? "admitted", reason, label); + } +}); From 55bb0a34244589f168f8a315b5e31175ad13c287 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:39:36 +0200 Subject: [PATCH 07/37] Pin the native envelope-hash binding in listing confirmation The metadata-mismatch case also broke native/transaction metadata equality, so a mutant dropping the envelopeHash comparison survived. Add the case where both metadata copies agree but neither hashes the anchored listing envelope. Co-Authored-By: Claude Opus 5.5 --- .../test/confirm-listing.test.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 048c0cb..4d39554 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -118,7 +118,15 @@ const request = (body: Record) => new NextRequest( ); /** Stub the Demos node: native readback, canonical write, and its block. */ -function stubChain(overrides: { nativeMetadata?: Record; block?: Record } = {}): void { +function stubChain(overrides: { + nativeMetadata?: Record; + transactionMetadata?: Record; + block?: Record; +} = {}): void { + const write = transaction.content.data[1] as Record; + const written = overrides.transactionMetadata + ? { ...transaction, content: { ...transaction.content, data: ["storageProgram", { ...write, metadata: overrides.transactionMetadata }] } } + : transaction; globalThis.fetch = async (_input, init) => { if (init?.method !== "POST") { return Response.json({ @@ -134,7 +142,7 @@ function stubChain(overrides: { nativeMetadata?: Record; block? } 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 === "getTransactions") return Response.json({ result: 200, response: [written] }); if (call.message === "getBlockByNumber" && call.data.blockNumber === 5) { return Response.json({ result: 200, @@ -205,13 +213,20 @@ test("listing confirmation never registers before the write is BFT-final", async test("listing confirmation rejects native metadata that does not bind the signed artifact", async () => { const originalFetch = globalThis.fetch; + const unbound = { ...metadata, envelopeHash: "0".repeat(64) }; try { - stubChain({ nativeMetadata: { ...metadata, envelopeHash: "0".repeat(64) } }); - const response = await POST(request(coordinates)); - assert.equal(response.status, 409); - const body = await response.json() as Record; - assert.equal(body.state, "finality-failed"); - assert.equal(body.anchorReceipt, undefined); + for (const overrides of [ + { nativeMetadata: unbound }, + // Native and transaction metadata agree, but neither hashes the anchored envelope. + { nativeMetadata: unbound, transactionMetadata: unbound }, + ]) { + stubChain(overrides); + const response = await POST(request(coordinates)); + assert.equal(response.status, 409, Object.keys(overrides).join("+")); + const body = await response.json() as Record; + assert.equal(body.state, "finality-failed"); + assert.equal(body.anchorReceipt, undefined); + } } finally { globalThis.fetch = originalFetch; } From f332290a6863442e956e025097a9ee41b1e20dc8 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:27:58 +0200 Subject: [PATCH 08/37] Pin the Directory to dacs-sdk 3904d70 f68fed1 refused Listings that the current SDK and Standard admit: APR-1 pay-alternative pipelines and CORE CF-5 nesting deeper than 64 levels. Move the pin to the current SDK revision. - The modules the Directory loads from the SDK reach only Node builtins at 3904d70, so none of the SDK's six new runtime dependencies is needed by the Directory. - The SDK's ClaimReference parser now reaches the browser bundle and imports node:net and node:url for domain hostnames. Add WHATWG browser shims next to the existing node:crypto and node:util ones, with a parity test against Node. - The signature depth sweep returns to main's 126-level threshold (CF-5 depth 128) and keeps 61-64 as guards against the old bound. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/next.config.mjs | 12 +++++++ .../dacs-directory/scripts/setup-sdk.sh | 2 +- .../dacs-directory/src/shims/node-net.ts | 31 +++++++++++++++++++ .../dacs-directory/src/shims/node-url.ts | 23 ++++++++++++++ .../dacs-directory/test/browser-shims.test.ts | 31 +++++++++++++++++++ .../test/scan-robustness.test.ts | 9 +++--- 6 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/shims/node-net.ts create mode 100644 reference-implementations/dacs-directory/src/shims/node-url.ts create mode 100644 reference-implementations/dacs-directory/test/browser-shims.test.ts diff --git a/reference-implementations/dacs-directory/next.config.mjs b/reference-implementations/dacs-directory/next.config.mjs index 817d651..f927f70 100644 --- a/reference-implementations/dacs-directory/next.config.mjs +++ b/reference-implementations/dacs-directory/next.config.mjs @@ -28,6 +28,18 @@ const nextConfig = { resource.request = new URL("./src/shims/node-util.ts", import.meta.url).pathname; }), ); + // The SDK's ClaimReference parser (CORE B.1 CF-2) checks `domain:` + // hostnames with node:net/node:url; the shims are the WHATWG subset. + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:net$/, (resource) => { + resource.request = new URL("./src/shims/node-net.ts", import.meta.url).pathname; + }), + ); + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/^node:url$/, (resource) => { + resource.request = new URL("./src/shims/node-url.ts", import.meta.url).pathname; + }), + ); config.plugins.push( new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"] }), ); diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index ddf5d16..5ba51cd 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="f68fed1c63b56a438830f13e706ea4651d675157" +SDK_REV="3904d704fb540e6e1d7dd502a0be29f20569c92d" # Railway's GitHub integration can check out this repository, but it does not # pass its credentials through to nested private-repository clones. Supply a diff --git a/reference-implementations/dacs-directory/src/shims/node-net.ts b/reference-implementations/dacs-directory/src/shims/node-net.ts new file mode 100644 index 0000000..1c5a8bd --- /dev/null +++ b/reference-implementations/dacs-directory/src/shims/node-net.ts @@ -0,0 +1,31 @@ +/** + * Browser subset used by the SDK's pure verification modules: `isIP`, which + * the SDK's DACS-1 DCR-2 hostname check uses to refuse IP literals. + */ +const IPV4_SEGMENT = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"; +// Node's own dotted-decimal IPv4 grammar (lib/internal/net.js). +const IPV4 = new RegExp(`^(?:${IPV4_SEGMENT}\\.){3}${IPV4_SEGMENT}$`); + +export function isIPv4(value: string): boolean { + return IPV4.test(value); +} + +export function isIPv6(value: string): boolean { + if (typeof value !== "string" || !value.includes(":")) return false; + // Node accepts a scope zone suffix; the WHATWG host parser does not. + const zone = value.indexOf("%"); + const address = zone < 0 ? value : value.slice(0, zone); + if (zone >= 0 && !/^%[^%\s]+$/.test(value.slice(zone))) return false; + if (/[^0-9A-Fa-f:.]/.test(address)) return false; + try { + new URL(`http://[${address}]/`); + return true; + } catch { + return false; + } +} + +export function isIP(value: string): 0 | 4 | 6 { + if (typeof value !== "string") return 0; + return isIPv4(value) ? 4 : isIPv6(value) ? 6 : 0; +} diff --git a/reference-implementations/dacs-directory/src/shims/node-url.ts b/reference-implementations/dacs-directory/src/shims/node-url.ts new file mode 100644 index 0000000..4d9f79b --- /dev/null +++ b/reference-implementations/dacs-directory/src/shims/node-url.ts @@ -0,0 +1,23 @@ +/** + * Browser subset used by the SDK's pure verification modules: the WHATWG + * domain conversions behind the SDK's DACS-1 DCR-1/DCR-2 hostname check. + * Node implements both as WHATWG URL host parsing, which browsers expose + * through `URL`. The SDK rejects URL structure (`:/@?#[]\%`, whitespace) + * before calling them, so the input cannot change the URL's shape. + */ +export function domainToASCII(domain: string): string { + try { + return new URL(`http://${domain}/`).hostname; + } catch { + return ""; + } +} + +/** + * Browsers expose no Unicode host serialization. Only the SDK's producer path + * (canonicalizing a native hostname) uses this; signed-artifact readers compare + * against an ASCII result. Returning "" makes that producer path fail closed. + */ +export function domainToUnicode(_domain: string): string { + return ""; +} diff --git a/reference-implementations/dacs-directory/test/browser-shims.test.ts b/reference-implementations/dacs-directory/test/browser-shims.test.ts new file mode 100644 index 0000000..081a0b4 --- /dev/null +++ b/reference-implementations/dacs-directory/test/browser-shims.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { isIP as nodeIsIP } from "node:net"; +import { domainToASCII as nodeDomainToASCII } from "node:url"; +import test from "node:test"; + +import { isIP } from "../src/shims/node-net.js"; +import { domainToASCII, domainToUnicode } from "../src/shims/node-url.js"; + +// The SDK's DACS-1 DCR-2 hostname check (identity/domainHost) reaches the +// browser bundle through its ClaimReference parser. Signed-artifact readers +// take its ASCII branch: domainToASCII, then isIP on the result and the input. +const HOSTS = [ + "example.com", "agent.example.com", "xn--bcher-kva.example", "xn--zz.example", "xn--a.example", + "a-b.example", "-a.example", "a..b", "localhost", "123.example", "1.2.3.4", "1.2.3", "01.2.3.4", + "0x7f.1", "4294967295", "256.1.1.1", "1.2.3.4.5", "example.com.", "EXAMPLE.com", "a".repeat(64) + ".example", + "a_b.example", "", "fe80::1", "::1", "1:2:3:4:5:6:7:8", "::ffff:1.2.3.4", "fe80::1%eth0", "1::2::3", ":::", "g::1", +]; + +test("the browser isIP shim agrees with node:net", () => { + for (const value of HOSTS) assert.equal(isIP(value), nodeIsIP(value), JSON.stringify(value)); +}); + +test("the browser domainToASCII shim agrees with node:url on the reader's inputs", () => { + for (const value of HOSTS.filter((host) => !/[:/@?#*\[\]\\%\s]/.test(host))) { + assert.equal(domainToASCII(value), nodeDomainToASCII(value), JSON.stringify(value)); + } +}); + +test("the browser domainToUnicode shim fails the producer path closed", () => { + assert.equal(domainToUnicode("xn--bcher-kva.example"), ""); +}); diff --git a/reference-implementations/dacs-directory/test/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index e78cebe..bd48121 100644 --- a/reference-implementations/dacs-directory/test/scan-robustness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-robustness.test.ts @@ -180,17 +180,16 @@ test("signature depth sweep bounds whole values and normalizes persisted envelop delete scope.signature; const extended = { ...scope, signature: { algorithm: "ed25519", signer: buyer, value: await signature(contentHash(scope), "dacs-bundle-binding:v1:", 0, "base64url") } }; - // The pinned SDK (dacs-sdk f68fed1) canonicalizes 64 container levels, the signature - // envelope included, so a member nested deeper than 62 has no canonical form there. - // That bound is below the Directory's own 128-level bound, which leaves 126 as the - // limit once the pin carries CF-5 depth 128 (dacs-sdk b819b32). + // CORE CF-5 admits 128 container levels (dacs-sdk b819b32, in the pin), the signature + // envelope included, so a member nested deeper than 126 has no canonical form. 61-64 + // stay in the sweep: the previous pin (f68fed1) refused everything deeper than 62. for (const depth of [61, 62, 63, 64, 124, 125, 126, 127, 128, 129, 4096, 4098, 4100]) { resetIndex([later], 6); chain({ [locator(4)]: { data: withSignatureMember(extended, nested(depth)) } }, [locator(4)]); await reindex(); assert.ok(store.loadCatalog().generatedAt > 6); const saved = store.loadScanState().bundleBindings?.job ?? []; - assert.equal(saved.length, depth <= 62 ? 1 : 0, `depth ${depth}`); + assert.equal(saved.length, depth <= 126 ? 1 : 0, `depth ${depth}`); if (saved.length) { assert.equal(Object.keys(saved[0].signature).length, 3); assert.equal(JSON.stringify(saved[0]), JSON.stringify(extended)); From fb5c0d0858c644b75ff2bb17b268c48b826782af Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:28:13 +0200 Subject: [PATCH 09/37] Authorize listing signers by CF-3 ClaimReference identity CORE B.1 CF-3 makes ClaimReference parameters advisory: identity is the scheme and identifier. The SDK's isListing authorizes the signer that way, but the Directory's Demos resolver accepted only the parameter-free form. A Listing signed as did:demos:agent:?channel=x therefore passed the SDK and failed here with LISTING_SIGNATURE_INVALID. claimRef.ts now reads exact CF-2 parameters and returns the parameter-free identity, as the SDK's parseDemosAgentClaimReference does. It mirrors that function instead of importing it because the SDK identity tree loads node:net and this module also runs in the browser; test/claim-ref.test.ts pins parity with the SDK on 35 inputs. Key resolution, signer authorization and seller matching all use the identity, so sellers stay keyed by the parameter-free claim, and registrations still require it. The SDK validation view keeps the parameters and only repairs the scheme token. Cross-version conformance cases, all refused at the previous pin: signed pay-alternative Listings (and SDK-refused incoherent ones), nesting through 128 levels, advisory parameters on each signed role (and non-canonical parameter forms refused). An indexer-level case covers all three and an expired Listing, which had no indexer test. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/claimRef.ts | 81 +++++++++- .../src/catalog/listingVerification.ts | 7 +- .../src/catalog/primaryClaimKey.ts | 5 +- .../dacs-directory/test/claim-ref.test.ts | 75 +++++++++ .../test/listing-admission.test.ts | 151 +++++++++++++++++- 5 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/claim-ref.test.ts diff --git a/reference-implementations/dacs-directory/src/catalog/claimRef.ts b/reference-implementations/dacs-directory/src/catalog/claimRef.ts index 738c0db..63527fd 100644 --- a/reference-implementations/dacs-directory/src/catalog/claimRef.ts +++ b/reference-implementations/dacs-directory/src/catalog/claimRef.ts @@ -7,16 +7,87 @@ * case-sensitive, and an uppercase key is non-canonical — a reader must * reject it rather than lowercase it. `demos:0x…` is substrate-address * notation, not a registered ClaimReference, and never resolves here. + * + * CORE B.1 CF-3: a reference may carry advisory `?key=value` parameters in + * exact CF-2 form; they do not participate in identity. This mirrors the + * SDK's `parseDemosAgentClaimReference` (`identity/demos.ts`), which this + * module cannot import because the SDK identity tree loads `node:net` and + * this code also runs in the browser. `test/claim-ref.test.ts` pins parity. */ -const DEMOS_AGENT_DID = /^[dD][iI][dD]:(demos:agent:[0-9a-f]{64})$/; +const DEMOS_AGENT_DID = /^[dD][iI][dD]:(demos:agent:[0-9a-f]{64})(?:\?(.*))?$/s; +const RESERVED_PARAMETER_CHARACTERS = new Set([":", "?", "&", "=", "%"]); -/** Canonical `did:demos:agent:…` form of a claim reference, or null. */ -export function canonicalDemosAgentClaim(value: string): string | null { +function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from(left, (value) => value.codePointAt(0)!); + const rightPoints = Array.from(right, (value) => value.codePointAt(0)!); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + if (leftPoints[index] !== rightPoints[index]) return leftPoints[index]! - rightPoints[index]!; + } + return leftPoints.length - rightPoints.length; +} + +/** CF-2 parameter segment: only the reserved set may be (upper-hex) escaped. */ +function decodeParameterSegment(value: string): string | null { + let decoded = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]!; + if (character === "%") { + const encoded = value.slice(index + 1, index + 3); + if (!/^[0-9A-F]{2}$/.test(encoded)) return null; + const replacement = String.fromCharCode(Number.parseInt(encoded, 16)); + if (!RESERVED_PARAMETER_CHARACTERS.has(replacement)) return null; + decoded += replacement; + index += 2; + continue; + } + if (RESERVED_PARAMETER_CHARACTERS.has(character)) return null; + decoded += character; + } + return decoded; +} + +/** CF-2 parameters: non-empty, unique keys in code-point order. */ +function canonicalParameters(query: string): boolean { + if (!query) return false; + const keys: string[] = []; + for (const parameter of query.split("&")) { + const equals = parameter.indexOf("="); + if (equals <= 0 || equals !== parameter.lastIndexOf("=")) return false; + const key = decodeParameterSegment(parameter.slice(0, equals)); + if (key === null || decodeParameterSegment(parameter.slice(equals + 1)) === null || keys.includes(key)) { + return false; + } + keys.push(key); + } + return keys.every((key, index) => index === 0 || compareCodePoints(keys[index - 1]!, key) < 0); +} + +function parseDemosAgentClaim(value: string): { reference: string; identity: string } | null { + if (value.normalize("NFC") !== value || /[\s\u0000-\u001f\u007f]/.test(value)) return null; const match = DEMOS_AGENT_DID.exec(value); - return match ? `did:${match[1]}` : null; + if (!match || (match[2] !== undefined && !canonicalParameters(match[2]))) return null; + const identity = `did:${match[1]}`; + return { reference: match[2] === undefined ? identity : `${identity}?${match[2]}`, identity }; +} + +/** + * Parameter-free CF-3 identity (`did:demos:agent:…`) of a Demos agent claim + * reference, or null. Matching and key resolution use this identity. + */ +export function canonicalDemosAgentClaim(value: string): string | null { + return parseDemosAgentClaim(value)?.identity ?? null; +} + +/** + * Exact CF-2 bytes of a Demos agent claim reference — the scheme token + * lowercased, every advisory parameter kept — or null. + */ +export function canonicalDemosAgentReference(value: string): string | null { + return parseDemosAgentClaim(value)?.reference ?? null; } -/** True when the value already is the canonical spelling. */ +/** True when the value already is the canonical, parameter-free identity. */ export function isCanonicalDemosAgentClaim(value: unknown): value is string { return typeof value === "string" && canonicalDemosAgentClaim(value) === value; } diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index ceb7fad..3d90400 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -8,7 +8,7 @@ import { type LegacyMvpListing, type Listing, } from "@kynesyslabs/dacs/artifacts"; -import { canonicalDemosAgentClaim } from "./claimRef.js"; +import { canonicalDemosAgentReference } from "./claimRef.js"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, @@ -75,12 +75,13 @@ function verificationMethodFailure(raw: Record): boolean { * 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. + * exact received bytes. Method text, key casing and advisory parameters are + * never rewritten. */ function listingValidationView(raw: Record): Record { const view = structuredClone(raw); const normalize = (value: unknown): unknown => typeof value === "string" - ? canonicalDemosAgentClaim(value) ?? value + ? canonicalDemosAgentReference(value) ?? value : value; const seller = record(view.seller); const identity = record(seller?.identity); diff --git a/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts b/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts index edd4e2d..88c5de1 100644 --- a/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts +++ b/reference-implementations/dacs-directory/src/catalog/primaryClaimKey.ts @@ -90,7 +90,10 @@ export function sameResolvedPrimaryClaim( left.publicKey.every((byte, index) => byte === right.publicKey[index]); } -/** Strict current-profile comparison: Demos scheme canonicalisation, no aliases. */ +/** + * Strict current-profile comparison: Demos scheme canonicalisation, no aliases. + * CORE B.1 CF-3 identity, so advisory ClaimReference parameters are ignored. + */ export function canonicalSigningIdentity(claim: string): string { return canonicalDemosAgentClaim(claim) ?? claim; } diff --git a/reference-implementations/dacs-directory/test/claim-ref.test.ts b/reference-implementations/dacs-directory/test/claim-ref.test.ts new file mode 100644 index 0000000..9a67d72 --- /dev/null +++ b/reference-implementations/dacs-directory/test/claim-ref.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseDemosAgentClaimReference, sameCanonicalClaimIdentity } from "@kynesyslabs/dacs/identity"; + +import { + canonicalDemosAgentClaim, + canonicalDemosAgentReference, + isCanonicalDemosAgentClaim, +} from "../src/catalog/claimRef.js"; +import { canonicalSigningIdentity, resolveDemosPrimaryClaimKey } from "../src/catalog/primaryClaimKey.js"; + +const key = "ab".repeat(32); +const claim = `did:demos:agent:${key}`; + +// claimRef.ts cannot import the SDK identity tree (it loads node:net and the +// module also runs in the browser), so it mirrors parseDemosAgentClaimReference. +const CASES = [ + claim, + `DID:demos:agent:${key}`, + `DiD:demos:agent:${key}`, + `${claim}?channel=directory`, + `${claim}?channel=directory&purpose=listing`, + `${claim}?a=`, + `${claim}?a=%3A%3F%26%3D%25`, + `${claim}?café=1`, + `${claim}?b=1&a=2`, + `${claim}?a=1&a=2`, + `${claim}?`, + `${claim}?a`, + `${claim}?=1`, + `${claim}?a=1=2`, + `${claim}?a=b?c`, + `${claim}?a=b:c`, + `${claim}?a=%41`, + `${claim}?a=%3a`, + `${claim}?a=%3`, + `${claim}?a=1&`, + `${claim}?a=b c`, + `${claim}?a=b\n`, + `${claim}?café=1`, + `did:demos:agent:${key.toUpperCase()}`, + `did:DEMOS:agent:${key}`, + `did:demos:agent:${key}0`, + `did:demos:agent:${key.slice(2)}`, + `did:demos:agent:${key}:extra`, + `did:demos:buyer`, + `did:other:agent:${key}`, + `demos:0x${key}`, + `domain:${key}`, + ` ${claim}`, + key, + "", +]; + +test("the Demos ClaimReference parser matches the SDK's CF-2 read and CF-3 identity", () => { + for (const value of CASES) { + const sdk = parseDemosAgentClaimReference(value); + assert.equal(canonicalDemosAgentClaim(value), sdk?.canonicalIdentity ?? null, JSON.stringify(value)); + assert.equal(canonicalDemosAgentReference(value), sdk?.canonicalReference ?? null, JSON.stringify(value)); + } +}); + +test("advisory parameters do not change identity, resolution, or the registration form", async () => { + const advisory = `${claim}?channel=directory`; + assert.equal(sameCanonicalClaimIdentity(advisory, claim), true); + assert.equal(canonicalSigningIdentity(advisory), canonicalSigningIdentity(claim)); + const resolved = await resolveDemosPrimaryClaimKey(advisory, "ed25519"); + assert.equal(resolved?.canonicalClaim, claim); + assert.deepEqual(resolved?.publicKey, Uint8Array.from(Buffer.from(key, "hex"))); + assert.equal(await resolveDemosPrimaryClaimKey(`${claim}?b=1&a=2`, "ed25519"), null); + // Registrations and other Directory-issued claims stay parameter-free. + assert.equal(isCanonicalDemosAgentClaim(claim), true); + assert.equal(isCanonicalDemosAgentClaim(advisory), false); +}); diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index 00696ff..f8586a6 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -44,12 +44,20 @@ const attested = (verificationMethod?: unknown): Obj => ({ ...(verificationMethod === undefined ? {} : { verificationMethod }), }); +/** ClaimReference bytes for each signed role; all name the same key by default. */ +interface ListingRefs { presentedBy: string; claim: string; signer: string } + /** A correctly signed current Listing; only the supplied fields vary. */ -function signedListing(listingId: string, deliverable: Obj, extra: Obj = {}): Obj { - const identity: Obj = { bundleVersion: "1", presentedBy: seller, presentedAt: 1, claims: [{ ref: seller }] }; +function signedListing( + listingId: string, + deliverable: Obj, + extra: Obj = {}, + refs: ListingRefs = { presentedBy: seller, claim: seller, signer: seller }, +): Obj { + const identity: Obj = { bundleVersion: "1", presentedBy: refs.presentedBy, presentedAt: 1, claims: [{ ref: refs.claim }] }; identity.presentation = { kind: "per-claim", - signatures: [{ ref: seller, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`) }], + signatures: [{ ref: refs.presentedBy, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`) }], }; const scope: Obj = { dacsVersion: "1", @@ -65,7 +73,7 @@ function signedListing(listingId: string, deliverable: Obj, extra: Obj = {}): Ob validity: { notBefore: 1 }, ...extra, }; - return { ...scope, signature: { algorithm: "ed25519", signer: seller, value: sign(`dacs-listing:v1:${contentHash(scope)}`) } }; + return { ...scope, signature: { algorithm: "ed25519", signer: refs.signer, value: sign(`dacs-listing:v1:${contentHash(scope)}`) } }; } // DACS-2 §7.4.1: the SDK's closed registry, one valid instance per variant. @@ -243,3 +251,138 @@ test("registration admits only an SDK-shaped, established, finalized receipt for assert.match(listingAnchorReceiptRejection(candidate, expected) ?? "admitted", reason, label); } }); + +// Conforming Listings that the previous SDK pin (f68fed1) refused: APR-1 +// pay-alternative, CORE CF-5 nesting through 128 levels, and CORE B.1 CF-3 +// advisory ClaimReference parameters. Admission must agree with the SDK. +const ALTERNATIVES = [{ railId: "pay-dem" }, { railId: "pay-x402" }]; +const alternativePipeline = (alternatives: Obj[]): Obj[] => [ + ...ATTESTED_PIPELINE.slice(0, 2), + { kind: "pay-alternative", parameters: { alternatives } }, + ATTESTED_PIPELINE[3], +]; +const payAlternative = (listingId: string, extra: Obj = {}): Obj => signedListing(listingId, attested({ kind: "self-signed" }), { + pipeline: alternativePipeline(ALTERNATIVES), + acceptedRails: ALTERNATIVES, + ...extra, +}); +/** A Listing whose deepest member sits `levels` containers deep, the Listing itself included. */ +const nestedListing = (levels: number): Obj => { + let member: unknown = "leaf"; + for (let level = 2; level < levels; level += 1) member = { n: member }; + return signedListing(`depth-${levels}`, attested({ kind: "self-signed" }), { terms: { extension: member } }); +}; +const advisory = `${seller}?channel=directory`; + +test("admission accepts an APR-1 pay-alternative listing and leaves its coherence to the SDK", async () => { + const listing = payAlternative("pay-alternative"); + assert.equal(isListing(listing), true); + assert.equal((await verifyListingResult(listing)).ok, true); + + const incoherent: Record = { + "a single alternative": payAlternative("one-alternative", { pipeline: alternativePipeline(ALTERNATIVES.slice(0, 1)) }), + "an alternative that is not an accepted rail": payAlternative("unaccepted-alternative", { + acceptedRails: [ALTERNATIVES[0], { railId: "pay-evm-erc20" }], + }), + "an alternative beside a direct pay phase": payAlternative("mixed-pay", { + pipeline: [...alternativePipeline(ALTERNATIVES).slice(0, 3), ATTESTED_PIPELINE[2], ATTESTED_PIPELINE[3]], + }), + }; + for (const [label, candidate] of Object.entries(incoherent)) { + assert.equal(isListing(candidate), false, label); + assert.deepEqual(await verifyListingResult(candidate), { ok: false, code: "NORMATIVE_LISTING_INVALID" }, label); + } +}); + +test("admission follows CF-5: listings nested through 128 levels are admitted, deeper ones are not", async () => { + for (const levels of [63, 64, 65, 96, 126, 127, 128, 129, 130]) { + const listing = levels <= 128 ? nestedListing(levels) : null; + if (listing) { + assert.equal(isListing(listing), true, `levels ${levels}`); + assert.equal((await verifyListingResult(listing)).ok, true, `levels ${levels}`); + continue; + } + // Deeper artifacts have no canonical form, so they cannot even be signed. + assert.throws(() => nestedListing(levels), `levels ${levels}`); + } + // Unsigned bytes past the bound still fail closed rather than throwing. + const tooDeep = nestedListing(128); + let leaf = (tooDeep.terms as Obj).extension as Obj; + while (typeof leaf.n === "object") leaf = leaf.n as Obj; + leaf.n = { n: "past the bound" }; + assert.equal(isListing(tooDeep), false); + assert.deepEqual(await verifyListingResult(tooDeep), { ok: false, code: "NORMATIVE_LISTING_INVALID" }); +}); + +test("admission authorizes the signer by CF-3 identity, ignoring advisory ClaimReference parameters", async () => { + const admitted: Record = { + "parameters on every role": { presentedBy: advisory, claim: advisory, signer: advisory }, + "parameters only on the signer": { presentedBy: seller, claim: seller, signer: advisory }, + "parameters only on the claim": { presentedBy: seller, claim: advisory, signer: seller }, + "several canonical parameters": { presentedBy: seller, claim: seller, signer: `${seller}?channel=directory&purpose=listing` }, + }; + for (const [label, refs] of Object.entries(admitted)) { + const listing = signedListing("advisory-claim", attested({ kind: "self-signed" }), {}, refs); + assert.equal(isListing(listing), true, label); + const result = await verifyListingResult(listing); + assert.equal(result.ok, true, label); + // The Directory keys sellers by the parameter-free identity. + assert.equal(result.ok && result.value.signer, seller, label); + assert.equal(result.ok && result.value.sellerClaim, seller, label); + } + + // Parameters outside the exact CF-2 form, or another identity, stay refused. + const otherKey = `did:demos:agent:${"7".repeat(64)}`; + const refused: Record = { + "parameters out of order": [{ presentedBy: seller, claim: seller, signer: `${seller}?purpose=listing&channel=directory` }, "NORMATIVE_LISTING_INVALID"], + "an empty parameter list": [{ presentedBy: seller, claim: seller, signer: `${seller}?` }, "NORMATIVE_LISTING_INVALID"], + "a duplicated parameter": [{ presentedBy: seller, claim: seller, signer: `${seller}?a=1&a=2` }, "NORMATIVE_LISTING_INVALID"], + "an unreserved escape": [{ presentedBy: seller, claim: seller, signer: `${seller}?channel=%41` }, "NORMATIVE_LISTING_INVALID"], + "a parameterised claim for another key": [{ presentedBy: seller, claim: `${otherKey}?channel=directory`, signer: seller }, "NORMATIVE_LISTING_INVALID"], + "a parameterised signer naming another key": [ + { presentedBy: otherKey, claim: otherKey, signer: `${otherKey}?channel=directory` }, + "LISTING_SIGNATURE_INVALID", + ], + }; + for (const [label, [refs, code]] of Object.entries(refused)) { + const listing = signedListing("advisory-claim", attested({ kind: "self-signed" }), {}, refs); + assert.deepEqual(await verifyListingResult(listing), { ok: false, code }, label); + } +}); + +test("the indexer publishes current-SDK listings under the registered identity and drops expired ones", async () => { + const anchors = { + [`stor-${"a".repeat(40)}`]: payAlternative("indexed-pay-alternative"), + [`stor-${"b".repeat(40)}`]: nestedListing(128), + [`stor-${"c".repeat(40)}`]: signedListing("indexed-advisory", attested({ kind: "self-signed" }), {}, { + presentedBy: advisory, claim: advisory, signer: advisory, + }), + [`stor-${"d".repeat(40)}`]: signedListing("indexed-expired", attested({ kind: "self-signed" }), { + validity: { notBefore: 1, notAfter: 2 }, + }), + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = anchors[String(input).split("/").pop() ?? ""]; + return data + ? Response.json({ success: true, owner: `0x${keyHex}`, programName: "opaque", data }) + : new Response(null, { status: 404 }); + }; + try { + const record = await indexRegistration( + { primaryClaim: seller, displayName: "Admission Desk", listingAnchors: Object.keys(anchors) }, + undefined, + async () => { throw new Error("identity unavailable"); }, + ); + assert.deepEqual( + record.listings.map((listing) => listing.listingId).sort(), + ["depth-128", "indexed-advisory", "indexed-pay-alternative"], + ); + for (const listing of record.listings) { + assert.equal(listing.status, "active", listing.listingId); + assert.equal(listing.seller.primaryClaim, seller, listing.listingId); + } + } finally { + globalThis.fetch = originalFetch; + } +}); From bde1ba45c44efe659cd6176aeef1c2603bbafebe Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:24:29 +0000 Subject: [PATCH 10/37] Isolate Directory local verification state and SDK checkout Co-Authored-By: Codex --- docs/codex-local-workflow.md | 56 +++++-- .../test/local-workflow.test.mjs | 157 ++++++++++++++++++ 2 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/local-workflow.test.mjs diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 8cbe7be..1de8d12 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -10,8 +10,9 @@ It uses the Node version and SDK revision already pinned by the repository. `reference-implementations/dacs-directory/.nvmrc` and `package.json`. - Do not copy `.env` files, tokens, wallets, seeds, `data/`, `node_modules/`, or `vendor/` from another worktree. -- Keep any mutable catalog state under this worktree's - `reference-implementations/dacs-directory/data/` directory. +- Keep development catalog state under this worktree's + `reference-implementations/dacs-directory/data/` directory. Verification uses + a separate disposable directory, as shown below. - Do not run the indexer or live/paid browser tests as part of local static and unit verification. They are separate, networked activities. @@ -47,14 +48,23 @@ building whatever the SDK checkout happens to contain. It builds only after sdk_rev=$(sed -n 's/^SDK_REV="\([0-9a-f]\{40\}\)"$/\1/p' scripts/setup-sdk.sh) [ "${#sdk_rev}" -eq 40 ] || die "no 40-hex SDK_REV found in scripts/setup-sdk.sh" - sdk=vendor/dacs-sdk + app_root=$(pwd -P) + sdk="$app_root/vendor/dacs-sdk" + # Reject redirected paths before cloning or changing any checkout. + [ ! -L "$app_root/vendor" ] || die "vendor must not be a symlink" + [ ! -L "$sdk" ] || die "$sdk must not be a symlink" if [ ! -e "$sdk" ]; then mkdir -p vendor git clone --filter=blob:none https://github.com/DACS-Agent-commerce/dacs-sdk.git "$sdk" fi - # An existing path must be its own Git checkout with no local changes. - [ "$(git -C "$sdk" rev-parse --show-toplevel)" = "$(cd "$sdk" && pwd -P)" ] \ + # Require a standalone checkout here, not a linked worktree or shared store. + [ "$(cd "$sdk" && pwd -P)" = "$sdk" ] || die "$sdk resolves outside this worktree" + [ -d "$sdk/.git" ] && [ ! -L "$sdk/.git" ] \ + || die "$sdk needs a private .git directory; linked worktrees are not supported" + [ ! -e "$sdk/.git/objects/info/alternates" ] \ + || die "$sdk shares an object store; use a standalone clone" + [ "$(git -C "$sdk" rev-parse --show-toplevel)" = "$sdk" ] \ || die "$sdk is not a Git checkout of its own; inspect it, do not overwrite it" [ -z "$(git -C "$sdk" status --porcelain)" ] \ || die "$sdk has local changes; inspect them, do not overwrite them" @@ -77,31 +87,41 @@ a remote URL or committed file. ## Run deterministic checks These are the same check commands as the Directory CI's typecheck, test, -origin-validation, and production-build steps. The install differs: CI runs -`npm run setup` (`npm install`), while this workflow uses `npm ci`. Use the same +origin-validation, and production-build steps. CI's `npm run setup` and the +installation above both use `npm ci` for the SDK and app. Use the same placeholder origins as the `env:` block of `.github/workflows/dacs-directory-ci.yml` (today `NEXT_PUBLIC_DIRECTORY_URL` and `NEXT_PUBLIC_BUTLER_ORIGIN`; drop any variable the workflow no longer sets): ```bash -npm run typecheck -npm test -NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ - NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ - npm run check:deploy-config -NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ - NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ - npm run build +( + set -euo pipefail + verification_data=$(mktemp -d "${TMPDIR:-/tmp}/dacs-directory-checks.XXXXXX") + trap 'rm -rf -- "$verification_data"' EXIT + export DACS_DIRECTORY_DATA="$verification_data" + + npm run typecheck + npm test + NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ + NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ + npm run check:deploy-config + NEXT_PUBLIC_DIRECTORY_URL=https://directory.ci.local \ + NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.ci.local \ + npm run build +) ``` These commands do not require the indexer, a wallet, paid commerce, or a shared -runtime data directory. Browser checks are separate from this deterministic -verification set. +runtime data directory. Store imports and build-time migrations use the temporary +catalog, which is removed on success or failure. The subshell leaves the +development data path selected above intact. Browser checks are separate from +this deterministic verification set. If interactive development is needed, first seed the worktree-local catalog the way `npm run setup` does. The seed is only read when the SQLite catalog is first -created, so copy it before the first `next dev`: +created, so copy it before any development command that imports the store, +including the first `next dev`: ```bash [ -f data/registrations.json ] || cp data/registrations.example.json data/registrations.json diff --git a/reference-implementations/dacs-directory/test/local-workflow.test.mjs b/reference-implementations/dacs-directory/test/local-workflow.test.mjs new file mode 100644 index 0000000..a8b6c97 --- /dev/null +++ b/reference-implementations/dacs-directory/test/local-workflow.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const app = fileURLToPath(new URL("../", import.meta.url)); +const guide = readFileSync(new URL("../../../docs/codex-local-workflow.md", import.meta.url), "utf8"); +const blocks = [...guide.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]); +const install = blocks.find((block) => block.includes("sdk_rev=")); +const checks = blocks.find((block) => block.includes("npm run typecheck")); +assert.ok(install && checks, "documented install and check commands are present"); +const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "dacs-local-workflow-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const bin = join(root, "bin"); + const cwd = join(root, "app"); + const temporary = join(root, "temporary"); + for (const path of [bin, cwd, temporary]) mkdirSync(path); + const log = join(root, "commands.jsonl"); + const env = { + ...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temporary, + WORKFLOW_LOG: log, WORKFLOW_REAL_GIT: realGit, WORKFLOW_APP: app, + DACS_DIRECTORY_DATA: join(cwd, "data"), + }; + writeFileSync(join(bin, "git"), `#!/usr/bin/env node +import { appendFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +const args = process.argv.slice(2); +if (args.some(a => ['clone', 'fetch', 'checkout'].includes(a))) { + appendFileSync(process.env.WORKFLOW_LOG, JSON.stringify({tool:'git', args})+'\\n'); + if (args.includes('fetch')) process.exit(0); // The pin is already available offline. + if (args[0] === 'clone') args.splice(1, args.length-2, '--no-local', process.env.WORKFLOW_SDK_SOURCE); +} +const run = spawnSync(process.env.WORKFLOW_REAL_GIT, args, {stdio:'inherit'}); +process.exit(run.status ?? 1); +`, { mode: 0o755 }); + writeFileSync(join(bin, "npm"), `#!/usr/bin/env node +import { appendFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +const args = process.argv.slice(2); +appendFileSync(process.env.WORKFLOW_LOG, JSON.stringify({tool:'npm', args, data:process.env.DACS_DIRECTORY_DATA})+'\\n'); +if (args[0] === 'test' || (args[1] === 'build' && process.env.WORKFLOW_CHECKS)) { + const run = spawnSync(process.execPath, ['--import','tsx','--input-type=module','--eval', + 'await import("./src/catalog/indexer.ts")'], {cwd:process.env.WORKFLOW_APP, env:process.env, stdio:'inherit'}); + if (run.status !== 0) process.exit(run.status ?? 1); +} +if (args[0] === 'test' && process.env.WORKFLOW_FAIL_TEST) process.exit(23); +`, { mode: 0o755 }); + const run = (block) => spawnSync("bash", ["-c", block], { cwd, env, encoding: "utf8", timeout: 30_000 }); + const commands = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : []; + return { root, cwd, temporary, env, run, commands }; +} + +function sdkFixture(f) { + const source = join(f.root, "source-sdk"); + mkdirSync(source); + const git = (...args) => execFileSync(realGit, ["-C", source, ...args], { encoding: "utf8" }); + git("init", "--quiet"); + writeFileSync(join(source, "README.md"), "isolated fixture\n"); + git("add", "README.md"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", "fixture"); + mkdirSync(join(f.cwd, "scripts")); + writeFileSync(join(f.cwd, "scripts/setup-sdk.sh"), `SDK_REV="${git("rev-parse", "HEAD").trim()}"\n`); + f.env.WORKFLOW_SDK_SOURCE = source; + return source; +} + +test("documented checks leave development data untouched and clean temporary catalogs", (t) => { + for (const fail of [false, true]) { + const f = fixture(t); + f.env.WORKFLOW_CHECKS = "1"; + if (fail) f.env.WORKFLOW_FAIL_TEST = "1"; + mkdirSync(f.env.DACS_DIRECTORY_DATA); + const seed = '[{"primaryClaim":"development-seed"}]\n'; + writeFileSync(join(f.env.DACS_DIRECTORY_DATA, "registrations.json"), seed); + // Exercise a fresh development directory and a previously used catalog. + const dbPath = join(f.env.DACS_DIRECTORY_DATA, "directory.sqlite"); + if (fail) { + const db = new Database(dbPath); + db.exec("CREATE TABLE developer_state (value TEXT); INSERT INTO developer_state VALUES ('preserve');"); + db.close(); + } + const before = fail ? readFileSync(dbPath) : null; + const run = f.run(`${checks}\nresult=$?\n[ "$DACS_DIRECTORY_DATA" = "$PWD/data" ] || exit 99\nexit "$result"`); + assert.equal(run.status, fail ? 23 : 0, run.stderr); + assert.deepEqual(readdirSync(f.env.DACS_DIRECTORY_DATA), fail ? ["directory.sqlite", "registrations.json"] : ["registrations.json"]); + if (fail) assert.deepEqual(readFileSync(dbPath), before); + assert.equal(readFileSync(join(f.env.DACS_DIRECTORY_DATA, "registrations.json"), "utf8"), seed); + const commands = f.commands(); + assert.equal(commands.length, fail ? 2 : 4); + for (const command of commands) { + assert.equal(dirname(command.data), f.temporary); + assert.notEqual(command.data, f.env.DACS_DIRECTORY_DATA); + assert.equal(existsSync(command.data), false, "private catalog removed even on failure"); + } + assert.deepEqual(readdirSync(f.temporary).filter((name) => name.startsWith("dacs-directory-checks.")), []); + } +}); + +test("documented SDK install rejects redirected and shared checkouts before mutations", (t) => { + for (const kind of ["vendor-symlink", "sdk-symlink", "dangling-symlink", "git-symlink", "linked-worktree", "shared-objects", "dirty"]) { + const f = fixture(t); + const source = sdkFixture(f); + const vendor = join(f.cwd, "vendor"); + const sdk = join(vendor, "dacs-sdk"); + if (kind === "vendor-symlink") { + const external = join(f.root, "external-vendor"); + mkdirSync(external); + cpSync(source, join(external, "dacs-sdk"), { recursive: true }); + symlinkSync(external, vendor); + } else { + mkdirSync(vendor); + if (kind === "sdk-symlink" || kind === "dangling-symlink") { + symlinkSync(kind === "sdk-symlink" ? source : join(f.root, "missing"), sdk); + } else if (kind === "linked-worktree") { + execFileSync(realGit, ["-C", source, "worktree", "add", "--quiet", "--detach", sdk]); + } else { + cpSync(source, sdk, { recursive: true }); + if (kind === "git-symlink") { + rmSync(join(sdk, ".git"), { recursive: true }); + symlinkSync(join(source, ".git"), join(sdk, ".git")); + } else if (kind === "shared-objects") { + writeFileSync(join(sdk, ".git/objects/info/alternates"), `${source}/.git/objects\n`); + } else writeFileSync(join(sdk, "README.md"), "local edit\n"); + } + } + const run = f.run(install); + assert.notEqual(run.status, 0, kind); + assert.match(run.stderr, /stopped:/, kind); + assert.deepEqual(f.commands(), [], `${kind}: no clone, fetch, checkout or npm`); + assert.equal(readFileSync(join(source, "README.md"), "utf8"), "isolated fixture\n"); + } +}); + +test("documented SDK install accepts new and existing private clean clones", (t) => { + for (const existing of [false, true]) { + const f = fixture(t); + const source = sdkFixture(f); + if (existing) { + mkdirSync(join(f.cwd, "vendor")); + cpSync(source, join(f.cwd, "vendor/dacs-sdk"), { recursive: true }); + } + const run = f.run(install); + assert.equal(run.status, 0, run.stderr); + const commands = f.commands(); + assert.equal(commands.filter((command) => command.tool === "git").length, existing ? 2 : 3); + assert.deepEqual(commands.filter((command) => command.tool === "npm").map((command) => command.args), [ + ["ci", "--no-audit", "--no-fund"], ["run", "build"], ["ci", "--no-audit", "--no-fund"], + ]); + } +}); From c59f84b04817cd7ce9c11a1b8159c5e305794762 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:24:36 +0000 Subject: [PATCH 11/37] Pin readiness migration and document historical anchor names Co-Authored-By: Codex --- .../src/catalog/legacySessionAnchorName.ts | 2 ++ .../test/listing-summary-migration.test.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts index 9967dd6..7473ce0 100644 --- a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts +++ b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts @@ -1,6 +1,8 @@ /** * Historical SDK-MVP program names used only while reading explicitly * labelled legacy bundles. These strings are not a current normative SDK API. + * Preserve the raw job ID used by historical anchors, including vet names; + * the current SDK's percent-encoded producer names address different anchors. */ export const legacySessionAnchorName = { agreement: (jobId: string): string => `dacs3:agreement:${jobId}`, diff --git a/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts b/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts index c1e257d..07824a7 100644 --- a/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts +++ b/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import Database from "better-sqlite3"; +import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -44,8 +45,13 @@ seeded.prepare("INSERT INTO kv_state(key,value_json,updated_at) VALUES ('catalog offering: { title: "test", category: "services.test", tags: [] }, pricing: {}, status: "revoked", + transactionReadiness: { disposition: "ready" }, revocationBinding, catalogObservedAt: 1, + }, { + listingId: "listing-2", + version: 1, + status: "active", }], deals: [], reputation: { completed: 0, bundleCount: 0, totalAgreements: 0, completionRate: null }, @@ -65,3 +71,24 @@ test("persisted revocationBinding summaries migrate to normative revocation once assert.deepEqual(listing?.revocation, revocationBinding); assert.equal("revocationBinding" in (listing ?? {}), false); }); + +test("startup migrates ready and missing readiness summaries once across restarts", () => { + const listings = store.loadCatalog().sellers[0]?.listings; + assert.equal(listings?.length, 2); + for (const listing of listings ?? []) { + assert.equal(listing.transactionReadiness?.disposition, "unassessed"); + assert.match(listing.transactionReadiness?.reason ?? "", /buyer-local/); + } + const db = new Database(join(dataDirectory, "directory.sqlite"), { readonly: true }); + try { + const readMigration = () => db.prepare("SELECT * FROM kv_state WHERE key IN ('catalog', 'listing-summary-readiness-schema-version') ORDER BY key").all(); + const before = readMigration(); + assert.equal((db.prepare("SELECT value_json FROM kv_state WHERE key='listing-summary-readiness-schema-version'").get() as { value_json: string }).value_json, "1"); + const restart = spawnSync(process.execPath, [ + "--import", "tsx", "--input-type=module", "--eval", + `await import(${JSON.stringify(new URL("../src/catalog/store.ts", import.meta.url).href)});`, + ], { env: { ...process.env, DACS_DIRECTORY_DATA: dataDirectory }, encoding: "utf8", timeout: 30_000 }); + assert.equal(restart.status, 0, restart.stderr); + assert.deepEqual(readMigration(), before, "a restart does not rewrite the migrated catalog or version marker"); + } finally { db.close(); } +}); From 29a4ca03f2fa72d732840cf74ede2a2b6ae92b9b Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:46:24 +0000 Subject: [PATCH 12/37] Keep Directory SDK installs in checkout-local Git storage Validate the resolved object directory and reject shared or redirected object stores before fetching. Scope Git path overrides to the selected checkout and exercise the documented workflow across standalone clones and rejected layouts. Co-Authored-By: Codex --- docs/codex-local-workflow.md | 20 +++++- .../test/local-workflow.test.mjs | 61 ++++++++++++++++--- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 1de8d12..2446491 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -45,6 +45,10 @@ building whatever the SDK checkout happens to contain. It builds only after set -euo pipefail die() { echo "stopped: $*" >&2; exit 1; } + # Resolve all Git paths from the selected checkout, not the caller's shell. + unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_SHALLOW_FILE + unset GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES + sdk_rev=$(sed -n 's/^SDK_REV="\([0-9a-f]\{40\}\)"$/\1/p' scripts/setup-sdk.sh) [ "${#sdk_rev}" -eq 40 ] || die "no 40-hex SDK_REV found in scripts/setup-sdk.sh" @@ -62,8 +66,17 @@ building whatever the SDK checkout happens to contain. It builds only after [ "$(cd "$sdk" && pwd -P)" = "$sdk" ] || die "$sdk resolves outside this worktree" [ -d "$sdk/.git" ] && [ ! -L "$sdk/.git" ] \ || die "$sdk needs a private .git directory; linked worktrees are not supported" - [ ! -e "$sdk/.git/objects/info/alternates" ] \ - || die "$sdk shares an object store; use a standalone clone" + objects="$sdk/.git/objects" + [ -d "$objects" ] && [ ! -L "$objects" ] \ + && [ "$(cd "$objects" && pwd -P)" = "$objects" ] \ + && [ "$(git -C "$sdk" rev-parse --path-format=absolute --git-path objects)" = "$objects" ] \ + || die "$sdk needs its own local .git/objects directory" + object_links=$(find "$objects" -type l -print -quit) || die "cannot inspect $objects" + [ -z "$object_links" ] || die "$sdk has redirected object-store entries; use a standalone clone" + for alternate in alternates http-alternates; do + [ ! -e "$objects/info/$alternate" ] && [ ! -L "$objects/info/$alternate" ] \ + || die "$sdk shares an object store; use a standalone clone" + done [ "$(git -C "$sdk" rev-parse --show-toplevel)" = "$sdk" ] \ || die "$sdk is not a Git checkout of its own; inspect it, do not overwrite it" [ -z "$(git -C "$sdk" status --porcelain)" ] \ @@ -84,6 +97,9 @@ continuing; do not run the checks below on a partial install. Access to the SDK repository may require your normal GitHub authentication; do not put a token in a remote URL or committed file. +The existing clone's `origin` is used for fetching, but its checked-out `HEAD` +must still match the exact SDK commit pinned by this repository. + ## Run deterministic checks These are the same check commands as the Directory CI's typecheck, test, diff --git a/reference-implementations/dacs-directory/test/local-workflow.test.mjs b/reference-implementations/dacs-directory/test/local-workflow.test.mjs index a8b6c97..d79512b 100644 --- a/reference-implementations/dacs-directory/test/local-workflow.test.mjs +++ b/reference-implementations/dacs-directory/test/local-workflow.test.mjs @@ -13,7 +13,17 @@ const blocks = [...guide.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => mat const install = blocks.find((block) => block.includes("sdk_rev=")); const checks = blocks.find((block) => block.includes("npm run typecheck")); assert.ok(install && checks, "documented install and check commands are present"); -const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); +const realGit = execFileSync("bash", ["-c", "command -v git"], { encoding: "utf8" }).trim(); +const gitPathVariables = [ + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_SHALLOW_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", +]; + +function snapshot(directory) { + return readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => [ + entry.name, entry.isDirectory() ? snapshot(join(directory, entry.name)) : readFileSync(join(directory, entry.name)), + ]); +} function fixture(t) { const root = mkdtempSync(join(tmpdir(), "dacs-local-workflow-")); @@ -29,11 +39,12 @@ function fixture(t) { DACS_DIRECTORY_DATA: join(cwd, "data"), }; writeFileSync(join(bin, "git"), `#!/usr/bin/env node -import { appendFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +const { appendFileSync } = require('node:fs'); +const { spawnSync } = require('node:child_process'); const args = process.argv.slice(2); if (args.some(a => ['clone', 'fetch', 'checkout'].includes(a))) { - appendFileSync(process.env.WORKFLOW_LOG, JSON.stringify({tool:'git', args})+'\\n'); + const gitPaths = Object.fromEntries(${JSON.stringify(gitPathVariables)}.filter(key => key in process.env).map(key => [key, process.env[key]])); + appendFileSync(process.env.WORKFLOW_LOG, JSON.stringify({tool:'git', args, gitPaths})+'\\n'); if (args.includes('fetch')) process.exit(0); // The pin is already available offline. if (args[0] === 'clone') args.splice(1, args.length-2, '--no-local', process.env.WORKFLOW_SDK_SOURCE); } @@ -41,8 +52,8 @@ const run = spawnSync(process.env.WORKFLOW_REAL_GIT, args, {stdio:'inherit'}); process.exit(run.status ?? 1); `, { mode: 0o755 }); writeFileSync(join(bin, "npm"), `#!/usr/bin/env node -import { appendFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; +const { appendFileSync } = require('node:fs'); +const { spawnSync } = require('node:child_process'); const args = process.argv.slice(2); appendFileSync(process.env.WORKFLOW_LOG, JSON.stringify({tool:'npm', args, data:process.env.DACS_DIRECTORY_DATA})+'\\n'); if (args[0] === 'test' || (args[1] === 'build' && process.env.WORKFLOW_CHECKS)) { @@ -104,7 +115,11 @@ test("documented checks leave development data untouched and clean temporary cat }); test("documented SDK install rejects redirected and shared checkouts before mutations", (t) => { - for (const kind of ["vendor-symlink", "sdk-symlink", "dangling-symlink", "git-symlink", "linked-worktree", "shared-objects", "dirty"]) { + for (const kind of [ + "vendor-symlink", "sdk-symlink", "dangling-symlink", "git-symlink", "linked-worktree", + "objects-symlink", "objects-pack-symlink", "objects-fanout-symlink", "common-directory", + "shared-objects", "http-alternates", "dangling-alternates", "dirty", + ]) { const f = fixture(t); const source = sdkFixture(f); const vendor = join(f.cwd, "vendor"); @@ -127,18 +142,33 @@ test("documented SDK install rejects redirected and shared checkouts before muta symlinkSync(join(source, ".git"), join(sdk, ".git")); } else if (kind === "shared-objects") { writeFileSync(join(sdk, ".git/objects/info/alternates"), `${source}/.git/objects\n`); + } else if (kind === "objects-symlink") { + rmSync(join(sdk, ".git/objects"), { recursive: true }); + symlinkSync(join(source, ".git/objects"), join(sdk, ".git/objects")); + } else if (kind === "objects-pack-symlink" || kind === "objects-fanout-symlink") { + const entry = kind === "objects-pack-symlink" ? "pack" : readdirSync(join(source, ".git/objects")).find(name => /^[0-9a-f]{2}$/.test(name)); + assert.ok(entry, "fixture has loose objects"); + rmSync(join(sdk, ".git/objects", entry), { recursive: true }); + symlinkSync(join(source, ".git/objects", entry), join(sdk, ".git/objects", entry)); + } else if (kind === "common-directory") { + writeFileSync(join(sdk, ".git/commondir"), `${source}/.git\n`); + } else if (kind === "http-alternates") { + writeFileSync(join(sdk, ".git/objects/info/http-alternates"), "https://example.invalid/objects\n"); + } else if (kind === "dangling-alternates") { + symlinkSync(join(f.root, "missing"), join(sdk, ".git/objects/info/alternates")); } else writeFileSync(join(sdk, "README.md"), "local edit\n"); } } + const before = snapshot(source); const run = f.run(install); assert.notEqual(run.status, 0, kind); assert.match(run.stderr, /stopped:/, kind); assert.deepEqual(f.commands(), [], `${kind}: no clone, fetch, checkout or npm`); - assert.equal(readFileSync(join(source, "README.md"), "utf8"), "isolated fixture\n"); + assert.deepEqual(snapshot(source), before, `${kind}: external checkout and object store are unchanged`); } }); -test("documented SDK install accepts new and existing private clean clones", (t) => { +test("documented SDK install accepts private clean clones and ignores Git path overrides", (t) => { for (const existing of [false, true]) { const f = fixture(t); const source = sdkFixture(f); @@ -146,10 +176,21 @@ test("documented SDK install accepts new and existing private clean clones", (t) mkdirSync(join(f.cwd, "vendor")); cpSync(source, join(f.cwd, "vendor/dacs-sdk"), { recursive: true }); } - const run = f.run(install); + Object.assign(f.env, { + GIT_DIR: join(source, ".git"), GIT_WORK_TREE: source, GIT_COMMON_DIR: join(source, ".git"), + GIT_INDEX_FILE: join(source, ".git/index"), GIT_SHALLOW_FILE: join(source, ".git/shallow"), + GIT_OBJECT_DIRECTORY: join(source, ".git/objects"), GIT_ALTERNATE_OBJECT_DIRECTORIES: join(source, ".git/objects"), + }); + const before = snapshot(source); + // Path neutralization stays within the subshell, preserving the caller's Git setup. + const run = f.run(`${install}\nresult=$?\n[ "$GIT_OBJECT_DIRECTORY" = "$WORKFLOW_SDK_SOURCE/.git/objects" ] || exit 99\nexit "$result"`); assert.equal(run.status, 0, run.stderr); const commands = f.commands(); assert.equal(commands.filter((command) => command.tool === "git").length, existing ? 2 : 3); + for (const command of commands.filter((command) => command.tool === "git")) { + assert.deepEqual(command.gitPaths, {}, "Git mutations use checkout-local paths"); + } + assert.deepEqual(snapshot(source), before, "external Git files are unchanged"); assert.deepEqual(commands.filter((command) => command.tool === "npm").map((command) => command.args), [ ["ci", "--no-audit", "--no-fund"], ["run", "build"], ["ci", "--no-audit", "--no-fund"], ]); From 766d042ccf82422bd7439f6c0df0cf9e45384399 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:27:24 +0200 Subject: [PATCH 13/37] Admit and publish current Listings exactly as the pinned SDK reads them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser publisher stored the wallet's hex signature as the Listing's signature value. CORE §B.7 SIG-6 defines that value as unpadded base64url, so SDK readers decoded 96 bytes and refused every Listing published through /register, while the Directory's hex-first decoder admitted them. Store base64url (the identity presentation too) and decode a current Listing's signature strictly as SIG-6 Ed25519. Apply isListing to the exact received bytes instead of a scheme-lowercased copy, so an uppercase DID: token inside a signed artifact fails here as it does at the SDK reader's first step. Hex-signed versions now leave the catalog but still occupy their owner-held program names. The publisher steps past up to four such versions so sellers can republish. check:listing-upgrade now verifies signatures and identity presentations against the signer's key instead of comparing the artifact with itself. Co-Authored-By: Claude Opus 5.5 --- .../app/api/dacs/build-listing/route.ts | 56 +++++++--- .../scripts/check-live-listing-upgrade.ts | 15 +-- .../src/catalog/listingVerification.ts | 68 ++++-------- .../src/catalog/sdkReaderVerifiers.ts | 54 +++++++++ .../dacs-directory/test/build-listing.test.ts | 82 +++++++++++++- .../test/confirm-listing.test.ts | 2 +- .../test/current-indexer.test.ts | 4 +- .../test/listing-verification.test.ts | 31 +++++- .../test/malformed-artifacts.test.ts | 3 +- .../test/sdk-reader-verifiers.test.ts | 105 ++++++++++++++++++ .../dacs-directory/test/verification.test.ts | 8 +- 11 files changed, 347 insertions(+), 81 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/catalog/sdkReaderVerifiers.ts create mode 100644 reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts 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 ab22b5e..acf6d76 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 @@ -47,6 +47,8 @@ import { const LISTING_SEPARATOR = "dacs-listing:v1:"; const BUNDLE_SEPARATOR = "dacs-bundle-presentation:v1:"; const PUBLISHABLE_DELIVERY = new Set(["deliver-attested-payload", "deliver-storage-program", "deliver-entitlement"]); +/** Owner-held versions that no longer authenticate and may be stepped past per request. */ +const MAX_OCCUPIED_VERSIONS = 4; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); async function accountNonce(addressHex: string): Promise { @@ -118,7 +120,7 @@ export async function POST(req: NextRequest) { // Stateless by design: an unauthenticated first-stage request must not // create mutable state that can block another publisher. Operators must // serialize concurrent writes for one seller/listingId (README limitation). - const listingVersion = Math.max(0, ...priorVersions) + 1; + let listingVersion = Math.max(0, ...priorVersions) + 1; const category = (body.category ?? "services.other").trim().toLowerCase(); if (!/^[a-z0-9.-]{1,64}$/.test(category)) { @@ -244,7 +246,10 @@ export async function POST(req: NextRequest) { seller: { identity: { ...identityScope, - presentation: { kind: "per-claim", signatures: [{ ref: did, signature: identitySigHex }] }, + presentation: { + kind: "per-claim", + signatures: [{ ref: did, signature: Buffer.from(identitySigHex, "hex").toString("base64url") }], + }, }, displayName: knownSeller?.displayName ?? body.name.trim(), ...(publicEndpoint ? { publicEndpoint } : {}), @@ -289,6 +294,31 @@ export async function POST(req: NextRequest) { { status: 409 }, ); } + + // 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 programNameFor = (version: number) => + logicalToStorageProgramName(listingAddress(did, serviceId, version)); + const recoverable = (verified: Awaited>) => Boolean( + verified && + verified.profile === "dacs-v0.1" && + verified.sellerClaim === did && + verified.scope.listingId === serviceId && + verified.scope.listingVersion === listingVersion, + ); + // Versions are immutable. A program at this owner's name whose artifact no + // longer authenticates (for example a Listing an earlier publisher signed + // with raw hex) occupies that version permanently and leaves the catalog, so + // step past a bounded number of them instead of refusing republication. + let resolution = await resolveOwnedAnchorByName(programNameFor(listingVersion), owner); + for (let skipped = 0; skipped < MAX_OCCUPIED_VERSIONS && resolution.status === "present"; skipped += 1) { + if (recoverable(await verifyListing(resolution.record.data))) break; + listingVersion += 1; + listing.listingVersion = listingVersion; + resolution = await resolveOwnedAnchorByName(programNameFor(listingVersion), owner); + } const hash = contentHash(listing as Record); const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII let signedCandidate: Record | undefined; @@ -305,9 +335,14 @@ export async function POST(req: NextRequest) { publicKeyFromRaw(Uint8Array.from(Buffer.from(hex, "hex"))), ); } catch { /* malformed signatures fail closed */ } + // Wallets return hex; CORE §B.7 SIG-6 stores canonical unpadded base64url. signedCandidate = { ...listing, - signature: { algorithm: "ed25519", signer: did, value: listingSigHex }, + signature: { + algorithm: "ed25519", + signer: did, + value: Buffer.from(listingSigHex, "hex").toString("base64url"), + }, }; if (!listingSignatureValid || !isListing(signedCandidate)) { return NextResponse.json({ error: "the signed listing failed current SDK validation" }, { status: 400 }); @@ -315,12 +350,7 @@ export async function POST(req: NextRequest) { } 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 = logicalToStorageProgramName(logicalAddress); - const resolution = await resolveOwnedAnchorByName(programName, owner); + const programName = programNameFor(listingVersion); if (resolution.status === "indeterminate") { return NextResponse.json( { error: `could not safely determine whether this listing version already exists: ${resolution.reason}` }, @@ -335,13 +365,7 @@ export async function POST(req: NextRequest) { const exists = resolution.status === "present"; if (resolution.status === "present") { const verified = await verifyListing(resolution.record.data); - if ( - !verified || - verified.profile !== "dacs-v0.1" || - verified.sellerClaim !== did || - verified.scope.listingId !== serviceId || - verified.scope.listingVersion !== listingVersion - ) { + if (!verified || !recoverable(verified)) { return NextResponse.json( { error: "the existing owner-bound program does not contain the expected authenticated listing candidate" }, { status: 409 }, diff --git a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts index 2fbdcbd..f6e388d 100644 --- a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -7,9 +7,12 @@ * 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 { + sdkIdentityPresentationVerifier, + sdkListingSignatureVerifier, +} from "../src/catalog/sdkReaderVerifiers.js"; import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; type Replacement = { @@ -78,10 +81,6 @@ async function anchor(locator: string): Promise> { 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(); @@ -130,8 +129,7 @@ async function main() { const validation = await validateListingArtifact(replacementRaw, { nowMs: () => Date.now(), - verifyListingSignature: ({ signature }) => - same(signature, listing.signature), + verifyListingSignature: sdkListingSignatureVerifier(), revocation: { surfaces: [{ kind: "catalog", @@ -141,8 +139,7 @@ async function main() { readMarker: async (marker) => anchor(marker.locator), verifyMarkerSignature: () => false, }, - verifyIdentityPresentation: ({ bundle }) => - same(bundle, listing.seller.identity), + verifyIdentityPresentation: sdkIdentityPresentationVerifier(), loadRailResolution: () => ({ trustPhase: "PA-1", trustPolicyAcceptsPA1: true, diff --git a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index 3d90400..da2251f 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,6 +1,7 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; import { ARTIFACT_SEPARATORS, + isCanonicalBase64Url, isListing, isVerificationMethod, readListingArtifact, @@ -8,7 +9,6 @@ import { type LegacyMvpListing, type Listing, } from "@kynesyslabs/dacs/artifacts"; -import { canonicalDemosAgentReference } from "./claimRef.js"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, @@ -23,6 +23,12 @@ import type { RevocationBinding } from "./types.js"; const SEPARATOR = "dacs-listing:v1:"; +/** + * Legacy-MVP raw hex or base64url. Used for legacy Listings, identity + * presentations (whose encoding the SDK leaves to the presentation verifier) + * and revocation markers, where accepting either encoding can only withdraw a + * Listing. Never used for a current Listing's own signature. + */ function decodeSignature(value: string): Uint8Array | null { const hex = value.replace(/^(0x)+/i, ""); if (/^[0-9a-fA-F]{128}$/.test(hex)) return Uint8Array.from(Buffer.from(hex, "hex")); @@ -34,6 +40,18 @@ function decodeSignature(value: string): Uint8Array | null { } } +/** + * CORE §B.7 SIG-6: a current ComponentSignature value is canonical unpadded + * base64url, and Ed25519 values decode to exactly 64 bytes. Raw hex is the + * legacy-MVP representation; 128 hex characters are valid base64url text but + * decode to 96 bytes, so SDK readers reject them. + */ +function decodeComponentSignature(value: string): Uint8Array | null { + if (!isCanonicalBase64Url(value)) return null; + const decoded = Buffer.from(value, "base64url"); + return decoded.length === 64 ? Uint8Array.from(decoded) : null; +} + interface VerifiedListingBase { scope: Record; contentHash: string; @@ -71,41 +89,6 @@ function verificationMethodFailure(raw: Record): boolean { ); } -/** - * 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, key casing and advisory parameters are - * never rewritten. - */ -function listingValidationView(raw: Record): Record { - const view = structuredClone(raw); - const normalize = (value: unknown): unknown => typeof value === "string" - ? canonicalDemosAgentReference(value) ?? value - : value; - const seller = record(view.seller); - const identity = record(seller?.identity); - 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 { @@ -164,13 +147,10 @@ async function verifyListingArtifact( ): 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)) { + // Validate the exact received bytes, as the pinned SDK reader does at step + // 1. CF-2 scheme-case leniency applies to standalone ClaimReference + // lookups, never to ClaimReferences inside a signed artifact. + if (!isListing(raw)) { return invalid(verificationMethodFailure(raw) ? "VERIFICATION_METHOD_INVALID" : "NORMATIVE_LISTING_INVALID"); @@ -192,7 +172,7 @@ async function verifyListingArtifact( return resolved; }, verify: ({ signedBytes, signature, publicKey }) => { - const decoded = decodeSignature(signature.value); + const decoded = decodeComponentSignature(signature.value); return Boolean( decoded && verifyResolvedPrimaryClaimSignature( signedBytes, diff --git a/reference-implementations/dacs-directory/src/catalog/sdkReaderVerifiers.ts b/reference-implementations/dacs-directory/src/catalog/sdkReaderVerifiers.ts new file mode 100644 index 0000000..1647d35 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/sdkReaderVerifiers.ts @@ -0,0 +1,54 @@ +import { + isCanonicalBase64Url, + type ComponentSignature, + type IdentityBundle, +} from "@kynesyslabs/dacs/artifacts"; +import { + resolveDemosPrimaryClaimKey, + verifyPrimaryClaimSignature, + type ResolvePrimaryClaimKey, +} from "./primaryClaimKey.js"; + +/** CORE §B.7 SIG-6 canonical unpadded base64url carrying a 64-byte Ed25519 value. */ +function sig6Ed25519(value: unknown): Uint8Array | null { + if (!isCanonicalBase64Url(value)) return null; + const bytes = Buffer.from(value, "base64url"); + return bytes.length === 64 ? Uint8Array.from(bytes) : null; +} + +/** + * Independent `verifyListingSignature` for the SDK's ordered Listing reader: + * decode the wire value as an SDK buyer does and verify it against the + * signer's resolved key. It never compares an artifact with itself. + */ +export function sdkListingSignatureVerifier( + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +) { + return async ({ signedBytes, signature }: { + signedBytes: Uint8Array; + signature: Readonly; + }): Promise => { + const bytes = sig6Ed25519(signature.value); + return Boolean(bytes && await verifyPrimaryClaimSignature( + signedBytes, bytes, signature.signer, signature.algorithm, resolveKey, + )); + }; +} + +/** Independent per-claim `verifyIdentityPresentation` for the presenting claim. */ +export function sdkIdentityPresentationVerifier( + resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, +) { + return async ({ bundle, signedBytes }: { + bundle: Readonly; + signedBytes: Uint8Array; + }): Promise => { + const presentation = bundle.presentation; + if (presentation.kind !== "per-claim") return false; + const entry = presentation.signatures.find((item) => item.ref === bundle.presentedBy); + const bytes = entry ? sig6Ed25519(entry.signature) : null; + return Boolean(bytes && await verifyPrimaryClaimSignature( + signedBytes, bytes, bundle.presentedBy, "ed25519", resolveKey, + )); + }; +} diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index ec5a439..5d6bf7a 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -5,9 +5,17 @@ import { join } from "node:path"; import test from "node:test"; import { NextRequest } from "next/server"; -import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { + ed25519Sign, + ed25519Verify, + privateKeyFromSeed, + publicKeyFromRaw, + publicKeyFromSeed, + rawPublicKey, +} from "@kynesyslabs/dacs/crypto"; import { deriveStorageAddress, LIVE_STORAGE_SALT } from "../src/catalog/chain.js"; import { verifyListing } from "../src/catalog/listingVerification.js"; +import { authenticateReadableListingArtifact } from "../vendor/dacs-sdk/dist/agent/discover.js"; const dataDir = mkdtempSync(join(tmpdir(), "dacs-build-listing-")); process.env.DACS_DIRECTORY_DATA = join(dataDir, "directory.sqlite"); @@ -151,6 +159,78 @@ test("publisher uses PA-1 rail resolution and signs the exact staged Listing bef assert.equal(built.contentHash.length, 64); assert.ok(await verifyListing(built.listing)); + const sig6 = /^[A-Za-z0-9_-]{86}$/; + assert.match((built.listing.signature as { value: string }).value, sig6, "CORE §B.7 SIG-6 base64url"); + const identityBundle = (built.listing.seller as { identity: { presentation: { signatures: Array<{ signature: string }> } } }).identity; + assert.match(identityBundle.presentation.signatures[0].signature, sig6); + assert.ok(await authenticateReadableListingArtifact(built.listing, { + verify: (bytes, signature, publicKey) => ed25519Verify(bytes, signature, publicKeyFromRaw(publicKey)), + }), "an SDK buyer authenticates the published Listing"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("publisher steps past an owner-held version whose artifact no longer authenticates", async () => { + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const walletHex = (message: string) => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("hex"); + const finalInput = { + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature: walletHex(identity.identityMessage), + }; + let occupied: { address: string; name: string; data: Record } | undefined; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + if (init?.method === "POST") { + const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string; data?: { query?: string } }> }; + if (rpc.params?.[0]?.message === "searchStoragePrograms") { + return Response.json({ + result: 200, + response: occupied && rpc.params[0].data?.query === occupied.name + ? [{ storageAddress: occupied.address, programName: occupied.name }] + : [], + }); + } + return Response.json({ result: 200, response: 0 }); + } + return occupied && String(input).endsWith(occupied.address) + ? Response.json({ success: true, data: occupied.data, owner: `0x${keyHex}`, programName: occupied.name }) + : new Response(null, { status: 404 }); + }; + type Built = { + listing: Record; + message: string; + logicalAddress: string; + programName: string; + anchorAddress: string; + exists: boolean; + tx: unknown; + }; + try { + const v1Preview = await (await POST(request(finalInput))).json() as Built; + const v1 = await (await POST(request({ ...finalInput, listingSignature: walletHex(v1Preview.message) }))).json() as Built; + // What the earlier browser publisher anchored: the same bytes with a raw hex signature value. + const signature = v1.listing.signature as Record & { value: string }; + occupied = { + address: v1.anchorAddress, + name: v1.programName, + data: { ...v1.listing, signature: { ...signature, value: Buffer.from(signature.value, "base64url").toString("hex") } }, + }; + assert.equal(await verifyListing(occupied.data), null); + + const preview = await (await POST(request(finalInput))).json() as Built; + assert.equal(preview.exists, false); + assert.equal(preview.listing.listingVersion, 2); + assert.match(preview.logicalAddress, /:metered-ap2:v2$/); + const builtResponse = await POST(request({ ...finalInput, listingSignature: walletHex(preview.message) })); + assert.equal(builtResponse.status, 200); + const built = await builtResponse.json() as Built; + assert.equal(built.listing.listingVersion, 2); + assert.notEqual(built.programName, occupied.name); + assert.ok(built.tx, "the republished version is a new write"); + assert.ok(await verifyListing(built.listing)); } finally { globalThis.fetch = originalFetch; } diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 4d39554..8f80be2 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -64,7 +64,7 @@ const listing = { signature: { algorithm: "ed25519", signer: sellerClaim, - value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("hex"), + value: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${listingHash}`, "utf8"), privateKey)).toString("base64url"), }, }; const logicalAddress = listingAddress(sellerClaim, scope.listingId, scope.listingVersion); diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index d9e1ce3..b0df804 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -30,7 +30,9 @@ const sign = async (raw: Obj, kind: "listing" | "agreement" | "evidence" | "rati : kind === "bundle" && raw.faultBundleVersion === "1" ? "dacs-fault-bundle:v1:" : prefixes[kind]; - const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString("hex"); + // Current Listings carry CORE §B.7 SIG-6 base64url; other fixtures keep hex. + const value = Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))) + .toString(kind === "listing" ? "base64url" : "hex"); return party ? { party: dids[signer], algorithm: "ed25519", value } : { signer: dids[signer], algorithm: "ed25519", value }; }; const ref = (at: string, raw: Obj, kind: Parameters[1], extra: Obj = {}) => ({ diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 5ce29f1..c0c9630 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -82,12 +82,35 @@ function signedCurrentListing( }; } -test("verifyListing canonicalizes mixed-case DID scheme spelling on read", async () => { +test("verifyListing applies the SDK shape gate to the exact signed bytes, including DID scheme case", async () => { const mixedCaseClaim = `DID:demos:agent:${publicKeyHex}`; - const verified = await verifyListing(signedCurrentListing({}, mixedCaseClaim)); + const everyRole = signedCurrentListing({}, mixedCaseClaim); + const lowercase = signedCurrentListing(); + // The signature envelope is outside the signed scope, so this value stays cryptographically valid. + const signerOnly = { + ...lowercase, + signature: { ...(lowercase.signature as Record), signer: mixedCaseClaim }, + }; + + for (const listing of [everyRole, signerOnly]) { + assert.equal(isListing(listing), false, "the pinned SDK reader rejects it at step 1"); + assert.deepEqual(await verifyListingResult(listing), { ok: false, code: "NORMATIVE_LISTING_INVALID" }); + } + assert.equal((await verifyListing(lowercase))?.signer, claim); +}); + +test("a current Listing signature must be CORE §B.7 SIG-6 base64url, never raw hex", async () => { + const listing = signedCurrentListing(); + const signature = listing.signature as Record & { value: string }; + const hex = Buffer.from(signature.value, "base64url").toString("hex"); + const withValue = (value: string) => ({ ...listing, signature: { ...signature, value } }); - assert.equal(verified?.signer, claim); - assert.equal(verified?.sellerClaim, claim); + assert.equal(isListing(withValue(hex)), true, "128 hex characters are also valid base64url text"); + assert.deepEqual(await verifyListingResult(withValue(hex)), { ok: false, code: "LISTING_SIGNATURE_INVALID" }); + assert.deepEqual(await verifyListingResult(withValue(hex.toUpperCase())), { ok: false, code: "LISTING_SIGNATURE_INVALID" }); + assert.equal((await verifyListingResult(withValue(`0x${hex}`))).ok, false); + assert.equal((await verifyListingResult(withValue(`${signature.value}==`))).ok, false); + assert.equal((await verifyListingResult(listing)).ok, true); }); test("verifyListing rejects suffix-shaped aliases even when their signatures are valid", async () => { diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index 5546464..7cfe9cb 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -39,7 +39,8 @@ const PREFIXES: Record = { const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "hex") => Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString(encoding); const signArtifact = async (raw: Obj, kind: ArtifactKind, signer: number, party = false) => { - const value = await signature(artifactHash(raw, kind), PREFIXES[kind], signer); + // Current Listings carry CORE §B.7 SIG-6 base64url; other fixtures keep hex. + const value = await signature(artifactHash(raw, kind), PREFIXES[kind], signer, kind === "listing" ? "base64url" : "hex"); return party ? { party: dids[signer], algorithm: "ed25519", value } : { signer: dids[signer], algorithm: "ed25519", value }; }; const ref = (at: string, raw: Obj, kind: ArtifactKind, extra: Obj = {}) => ({ diff --git a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts new file mode 100644 index 0000000..49b6bce --- /dev/null +++ b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; +import { + sdkIdentityPresentationVerifier, + sdkListingSignatureVerifier, +} from "../src/catalog/sdkReaderVerifiers.js"; +import { validateListingArtifact, type ListingValidationDeps } from "../src/sdkListingValidation.js"; + +const seed = Uint8Array.from(Buffer.alloc(32, 31)); +const privateKey = privateKeyFromSeed(seed); +const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; +const otherClaim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 32))))).toString("hex")}`; +const sign = (message: string) => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); + +const identityScope = { bundleVersion: "1", presentedBy: claim, presentedAt: 1, claims: [{ ref: claim }] }; +const scope = { + dacsVersion: "1", + listingId: "reader-verifiers", + listingVersion: 1, + requiredCapabilities: ["SR-2"], + seller: { + identity: { + ...identityScope, + presentation: { + kind: "per-claim", + signatures: [{ ref: claim, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identityScope)}`) }], + }, + }, + displayName: "Reader verifier fixture", + }, + offering: { + title: "Reader verifier fixture", + description: "Pins the deployment acceptance verifiers.", + category: "services.test", + tags: [], + deliverable: { kind: "storage-program", accessModel: "public" }, + }, + buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, + pipeline: [ + { kind: "negotiate-fixed-price" }, + { kind: "commit-agreement" }, + { kind: "pay-dem", parameters: { rail: "pay-dem" } }, + { kind: "deliver-storage-program" }, + ], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "pay-dem" }], + terms: {}, + validity: { notBefore: 1 }, +}; +const listing = { + ...scope, + signature: { algorithm: "ed25519" as const, signer: claim, value: sign(`dacs-listing:v1:${contentHash(scope)}`) }, +}; +const hexValue = Buffer.from(listing.signature.value, "base64url").toString("hex"); + +function readerDeps(): ListingValidationDeps { + return { + nowMs: () => 2, + verifyListingSignature: sdkListingSignatureVerifier(), + revocation: { + surfaces: [{ kind: "catalog", status: "active", catalogObservedAt: 2 }], + readMarker: async () => null, + verifyMarkerSignature: () => false, + }, + verifyIdentityPresentation: sdkIdentityPresentationVerifier(), + loadRailResolution: () => ({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + }), + resolvePayloadVerificationCapability: () => ({ disposition: "unsupported", reason: "not configured" }), + verifySellerControl: ({ bundle, signer }) => signer === bundle.presentedBy, + } as ListingValidationDeps; +} + +test("acceptance verifiers verify SIG-6 values against the signer key, never by self-comparison", async () => { + const verifyListingSignature = sdkListingSignatureVerifier(); + const signedBytes = Buffer.from(`dacs-listing:v1:${contentHash(scope)}`, "utf8"); + assert.equal(await verifyListingSignature({ signedBytes, signature: listing.signature }), true); + assert.equal(await verifyListingSignature({ signedBytes, signature: { ...listing.signature, value: hexValue } }), false); + assert.equal(await verifyListingSignature({ signedBytes, signature: { ...listing.signature, signer: otherClaim } }), false); + assert.equal(await verifyListingSignature({ + signedBytes: Buffer.from(`dacs-listing:v1:${"0".repeat(64)}`, "utf8"), + signature: listing.signature, + }), false); + + const verifyIdentityPresentation = sdkIdentityPresentationVerifier(); + const bundle = scope.seller.identity as unknown as Parameters[0]["bundle"]; + const presentationBytes = Buffer.from(`dacs-bundle-presentation:v1:${contentHash(identityScope)}`, "utf8"); + assert.equal(await verifyIdentityPresentation({ bundle, signedBytes: presentationBytes }), true); + assert.equal(await verifyIdentityPresentation({ bundle, signedBytes }), false); +}); + +test("the SDK ordered reader with the acceptance verifiers accepts SIG-6 and rejects the same bytes as hex", async () => { + assert.equal((await validateListingArtifact(listing, readerDeps())).disposition, "verified"); + const hexListing = { ...listing, signature: { ...listing.signature, value: hexValue } }; + const rejected = await validateListingArtifact(hexListing, readerDeps()); + assert.equal(rejected.disposition, "rejected"); +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index e3350a0..98657ea 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -121,7 +121,7 @@ test("listing verification accepts a current structured listing and verifies its const value = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(currentScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const signed = { ...currentScope, signature: { algorithm: "ed25519", signer: did, value } }; const verified = await verifyListing(signed); assert.equal(verified?.profile, "dacs-v0.1"); @@ -131,7 +131,7 @@ test("listing verification accepts a current structured listing and verifies its const unsafeValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(unsafeScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); assert.equal(await verifyListing({ ...unsafeScope, signature: { algorithm: "ed25519", signer: did, value: unsafeValue } }), null); }); @@ -849,7 +849,7 @@ test("listing verification is open-world: an unknown additive top-level field is // top-level allowlist would (wrongly) reject this. const acceptValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(forwardScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const accepted = await verifyListing({ ...forwardScope, signature: { algorithm: "ed25519", signer: did, value: acceptValue } }); assert.ok(accepted, "unknown additive top-level field must be tolerated (open-world)"); assert.equal(accepted?.profile, "dacs-v0.1"); @@ -859,7 +859,7 @@ test("listing verification is open-world: an unknown additive top-level field is // open-world relaxation did NOT weaken signature binding. const baseValue = Buffer.from(await ed25519Sign( Buffer.from(`dacs-listing:v1:${contentHash(baseScope)}`, "utf8"), privateKeyFromSeed(seed), - )).toString("hex"); + )).toString("base64url"); const injected = { ...baseScope, futureListingField: { evil: true }, signature: { algorithm: "ed25519", signer: did, value: baseValue } }; assert.equal(await verifyListing(injected), null, "field injected after signing must fail the whole-scope hash"); }); From 5d0cd07ffd21150124310626362cc3fbb81c07f0 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:27:31 +0200 Subject: [PATCH 14/37] Never republish a listing as active while its revocation is unreadable An unreadable revocation marker made findValidListingRevocation return null, and the indexer then emitted a fresh `active` summary. SDK readers take that as established revocation absence, so a transient outage could turn a revoked Listing back into a verified one. A revocation this catalog already published now stays in force while its marker cannot be read. When the node cannot serve a verified marker that has no published binding, or a candidate anchored by the listing's owner, the listing is withheld for that pass. Candidates anyone else anchored, and candidates that are absent rather than unavailable, cannot withhold a listing. A new readAnchorOutcome separates node failures (network, 5xx, 429) from answers that hold no artifact. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/chain.ts | 26 +++++++++- .../dacs-directory/src/catalog/indexer.ts | 34 +++++++++++-- .../dacs-directory/src/catalog/store.ts | 7 +++ .../dacs-directory/test/scan-fairness.test.ts | 48 +++++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index e7e9126..092a09e 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -47,12 +47,34 @@ export interface AnchorRecord { } export async function readAnchorRecord(address: string): Promise { - if (!/^stor-[0-9a-f]{40}$/.test(address)) return null; + const outcome = await readAnchorOutcome(address); + return outcome.status === "found" ? outcome.record : null; +} + +/** + * Like readAnchorRecord, but separates a node that could not answer (network + * failure, timeout, 5xx, 429) from an answer that holds no readable artifact. + */ +export async function readAnchorOutcome( + address: string, +): Promise<{ status: "found"; record: AnchorRecord } | { status: "absent" | "unavailable" }> { + if (!/^stor-[0-9a-f]{40}$/.test(address)) return { status: "absent" }; + let res: Response; try { - const res = await fetch(`${RPC}/storage-program/${address}`, { + res = await fetch(`${RPC}/storage-program/${address}`, { cache: "no-store", signal: AbortSignal.timeout(15_000), }); + } catch { + return { status: "unavailable" }; + } + if (res.status >= 500 || res.status === 429) return { status: "unavailable" }; + const record = await anchorRecordFromResponse(res); + return record ? { status: "found", record } : { status: "absent" }; +} + +async function anchorRecordFromResponse(res: Response): Promise { + try { if (!res.ok) return null; const json = (await storageResponseJson(res)) as { success?: boolean; diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 32de86e..496ef23 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -26,7 +26,7 @@ import { isLegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; // import) and breaks the production webpack build. Reach into the leaf // module directly to keep this file free of the substrate client. import { verifyBundleCore } from "../../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; -import { readAnchor, readAnchorRecord } from "./chain.js"; +import { readAnchor, readAnchorOutcome, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; import { findValidListingRevocation, @@ -44,6 +44,7 @@ import { verifyOwnerSignature } from "./registrationSig.js"; import { artifactAnchorTime, clearListingRejection, + isOwnerRevocationCandidate, nextRevocationCandidates, resolveProgramAddress, loadScanState, @@ -81,6 +82,7 @@ import type { ListingSummary, Registration, RegisteredDeal, + RevocationBinding, SellerRecord, } from "./types.js"; @@ -120,6 +122,16 @@ export function listingIsCurrentlyEffective( return true; } +/** A revocation this catalog already published stays in force while its marker is unreadable. */ +function priorUnreadRevocation( + prior: SellerRecord | undefined, + contentHash: string, + unread: readonly string[], +): RevocationBinding | null { + return prior?.listings.find((listing) => listing.contentHash === contentHash && listing.revocation && + unread.includes(listing.revocation.markerAnchor.locator))?.revocation ?? null; +} + /** * Index one registration into a verified SellerRecord. * @@ -221,19 +233,33 @@ export async function indexRegistration( // those anchored by the listing's own owner first. Only one batch is ever selected. const limit = REVOCATION_VERIFICATIONS_PER_PASS; const progress: RevocationCandidateProgress = { unread: [], rejected: [] }; + const knownMarkers = (verifiedRevocations[verified.contentHash] ?? []).slice(0, limit); + const unavailableMarkers = new Set(); const revocation = await findValidListingRevocation( [ - ...(verifiedRevocations[verified.contentHash] ?? []).slice(0, limit), + ...knownMarkers, ...nextRevocationCandidates(verified.contentHash, anchored.owner ?? null, limit), ], verified, version, - readAnchor, + async (ref) => { + const outcome = await readAnchorOutcome(ref); + if (outcome.status === "unavailable") unavailableMarkers.add(ref); + return outcome.status === "found" ? outcome.record.data : null; + }, undefined, progress, limit, - ); + ) ?? priorUnreadRevocation(prior, verified.contentHash, progress.unread); recordRevocationProgress(verified.contentHash, progress); + // SDK readers take a fresh `active` summary without a binding as established + // revocation absence, so a marker the node could not serve must never + // produce one. A published binding stays revoked (above); a verified marker + // without one, or a candidate the listing owner anchored, withholds the + // listing until it can be read. Anyone can anchor other candidates, so an + // outage on those cannot withhold it. + if (!revocation && [...unavailableMarkers].some((ref) => knownMarkers.includes(ref) || + isOwnerRevocationCandidate(verified.contentHash, ref, anchored.owner ?? null))) continue; const presentation = listingPresentation(scope); const signedSeller = scope.seller && typeof scope.seller === "object" && !Array.isArray(scope.seller) ? scope.seller as Record : null; diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index b45db99..396afaf 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -364,6 +364,13 @@ export function nextRevocationCandidates(listingHash: string, listingOwner: stri const owned = listingOwner ? rows(pendingOwnerCandidates, listingHash, canonicalProgramOwner(listingOwner), limit) : []; return [...new Set([...owned, ...rows(pendingCandidates, listingHash, limit)])].slice(0, limit); } +const candidateOwner = db.prepare("SELECT owner FROM revocation_candidates WHERE listing_hash = ? AND locator = ?"); +/** Whether the listing's own owner anchored this revocation candidate; anyone else may anchor one too. */ +export function isOwnerRevocationCandidate(listingHash: string, locator: string, listingOwner: string | null): boolean { + if (!listingOwner) return false; + const row = candidateOwner.get(listingHash, locator) as { owner: string | null } | undefined; + return Boolean(row?.owner) && row?.owner === canonicalProgramOwner(listingOwner); +} const rejectCandidate = db.prepare("UPDATE revocation_candidates SET state='rejected' WHERE listing_hash = ? AND locator = ?"); const requeueCandidate = db.prepare(`UPDATE revocation_candidates SET seq = ? WHERE listing_hash = ? AND locator = ? AND state = 'pending'`); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 9678393..f83224b 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -669,6 +669,54 @@ test("a successful read without content ends the rejected class", async () => { assert.equal(sql("SELECT status FROM artifacts WHERE locator=?", at)[0].status, "dead-letter"); }); +test("an unreadable verified revocation marker never republishes its listing as freshly active", async () => { + resetIndex([sellerRegistration, later], 30); + chain({ [locator(1)]: { data: JSON.stringify(listing) }, [locator(60)]: { data: JSON.stringify(marker) } }, [locator(1), locator(60)]); + await reindex(); + const summary = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]; + const published = summary(); + assert.equal(published?.status, "revoked"); + assert.ok(published?.revocation); + + transactions = []; + unavailable.add(locator(60)); + await reindex(); + assert.equal(summary()?.status, "revoked", "the published binding stays in force"); + assert.deepEqual(summary()?.revocation, published.revocation); + + // Without a published binding to carry, the verified marker still withholds the listing. + store.saveCatalog({ catalogVersion: "1", generatedAt: 31, sellers: [] }); + await reindex(); + assert.equal(summary(), undefined); + + unavailable.clear(); + await reindex(); + assert.equal(summary()?.status, "revoked"); +}); + +test("an unavailable candidate anchored by the listing owner withholds the listing; another writer's or a vanished one does not", async () => { + for (const [writer, after, expected] of [ + [outsider, "unavailable", "active"], + [seller, "gone", "active"], + [seller, "unavailable", undefined], + ] as const) { + resetIndex([sellerRegistration, later], 32); + const programs: Record = { [locator(1)]: { data: JSON.stringify(listing) } }; + const candidate = { data: JSON.stringify(marker), owner: ownerOf(writer) }; + let served = false; + // The scan reads the candidate once; afterwards the node answers 503 or 404. + Object.defineProperty(programs, locator(61), { + enumerable: true, + get: () => after === "unavailable" || !served ? (served = true, candidate) : undefined, + }); + chain(programs, [locator(1), locator(61)]); + if (after === "unavailable") scanOnly.add(locator(61)); + await reindex(); + assert.deepEqual(pending(listingHash), [locator(61)], "the candidate was discovered but not read"); + assert.equal(listingStatus(), expected, `${writer} ${after}`); + } +}); + test("a transient retry is read before rejected retries and the revocation it carries is applied", async () => { resetIndex([sellerRegistration, later], 10); chain({ [locator(1)]: { data: JSON.stringify(listing) }, [locator(60)]: { data: JSON.stringify(marker) } }, [locator(1), locator(60)]); From 0b61ec4cc8f1bedbd965520f9f03595c89b41bc5 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:27:39 +0200 Subject: [PATCH 15/37] Scope the finality claim to browser publication and correct Directory docs The finalized AnchorReceipt gates only the /register browser flow: registrations do not carry it, POST /api/dacs/register accepts pointer sets from any client, and catalog admission rests on the indexer re-reading and re-authenticating every anchor. Say so in the README and the prepare-registration route comment instead of describing the receipt as a catalog registration requirement. Also document SIG-6 admission on exact bytes, independent verification in check:listing-upgrade, the node:net/node:url browser shims, revocation handling during marker outages and stepping past unauthenticated versions. The local workflow guide now rejects symlinked .git/shallow and FETCH_HEAD and states its Git 2.31 requirement. Co-Authored-By: Claude Opus 5.5 --- docs/codex-local-workflow.md | 7 +++++- .../dacs-directory/README.md | 25 +++++++++++++------ .../api/dacs/prepare-registration/route.ts | 4 ++- .../test/local-workflow.test.mjs | 6 ++++- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 2446491..8b2cc41 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -38,7 +38,9 @@ reuse a clean vendor checkout in this worktree and install both dependency trees from their lockfiles. The block runs in a subshell with `set -euo pipefail`, so pasting it into an interactive shell stops at the first failure instead of building whatever the SDK checkout happens to contain. It builds only after -`HEAD` is verified to be the pinned commit: +`HEAD` is verified to be the pinned commit. The object-store check uses +`git rev-parse --path-format=absolute`, which needs Git 2.31 or later; older Git +stops at that check: ```bash ( @@ -77,6 +79,9 @@ building whatever the SDK checkout happens to contain. It builds only after [ ! -e "$objects/info/$alternate" ] && [ ! -L "$objects/info/$alternate" ] \ || die "$sdk shares an object store; use a standalone clone" done + for metadata in shallow FETCH_HEAD; do + [ ! -L "$sdk/.git/$metadata" ] || die "$sdk/.git/$metadata must not be a symlink" + done [ "$(git -C "$sdk" rev-parse --show-toplevel)" = "$sdk" ] \ || die "$sdk is not a Git checkout of its own; inspect it, do not overwrite it" [ -z "$(git -C "$sdk" status --porcelain)" ] \ diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 38ebec1..a24e7a4 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -7,7 +7,8 @@ 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 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 +listings must pass the pinned SDK's normative `isListing()` gate on their exact signed bytes, with CORE §B.7 +SIG-6 base64url signature values, 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. @@ -23,7 +24,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` 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 | +| 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 the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | | 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 | @@ -201,7 +202,8 @@ 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 +SDK reader with its Listing signature and identity presentation verified independently +(SIG-6 base64url, Ed25519 against the signer's key), 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. @@ -224,7 +226,10 @@ private gateway release and are not Directory/indexer acceptance criteria. group, the first distinct locators in scan iteration order survive. A valid marker outside the retained window is not evaluated. Its publisher can anchor a fresh marker to re-enter discovery, but continued overflow can exclude that marker again. - After one marker verifies, later pruning cannot make it disappear. + After one marker verifies, later pruning cannot make it disappear, and an unreadable + marker never turns its listing back into a fresh `active` summary: a published + revocation stays in force, and a pass that cannot get a verified marker or a candidate + anchored by the listing's owner from the node withholds the listing until it can. BB-4-valid BundleBindings are also classified and accumulated under a deterministic per-job/role total-work ceiling; any overflow is sticky and makes that side `indeterminate`, never absent. @@ -246,7 +251,8 @@ is bundler-hostile. SDK verification names resolve through one compatibility sea 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). +shim over JSON-owned values, WHATWG-subset `node:net` and `node:url` shims, and +base64url-patched Buffer). ## Honest limitations (MVP) @@ -263,7 +269,10 @@ shim over JSON-owned values, and base64url-patched Buffer). 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 + `AnchorReceipt`, and independent native readback. That wait protects this browser flow + only: registrations do not carry the receipt, `POST /api/dacs/register` accepts pointer + sets from any client, and catalog admission rests on the indexer re-reading and + re-authenticating each anchor rather than on transaction finality. 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 @@ -281,7 +290,9 @@ shim over JSON-owned values, and base64url-patched Buffer). - **Listing versions are allocated from observed catalog state**, without a mutable in-process lock. Publishers must serialize writes for one `seller + listingId` until the substrate or SDK provides an atomic version allocator; concurrent publishers can - otherwise propose the same next version. + otherwise propose the same next version. A version whose owner-held program no longer + authenticates (for example a Listing an earlier publisher signed with raw hex) is + stepped past, up to four per request. ## DACS surface / conformance declaration 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 04c59c5..1145604 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,8 @@ /** * POST /api/dacs/prepare-registration — issue a fresh, content-bound owner - * signing message for an already finalized and authenticated listing pointer. + * signing message for a registration pointer set. It only parses and signs: + * the `/register` page calls it after confirm-listing has checked finality, + * and the indexer re-authenticates every anchor before catalog admission. * * 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/test/local-workflow.test.mjs b/reference-implementations/dacs-directory/test/local-workflow.test.mjs index d79512b..ed78381 100644 --- a/reference-implementations/dacs-directory/test/local-workflow.test.mjs +++ b/reference-implementations/dacs-directory/test/local-workflow.test.mjs @@ -118,7 +118,7 @@ test("documented SDK install rejects redirected and shared checkouts before muta for (const kind of [ "vendor-symlink", "sdk-symlink", "dangling-symlink", "git-symlink", "linked-worktree", "objects-symlink", "objects-pack-symlink", "objects-fanout-symlink", "common-directory", - "shared-objects", "http-alternates", "dangling-alternates", "dirty", + "shared-objects", "http-alternates", "dangling-alternates", "shallow-symlink", "fetch-head-symlink", "dirty", ]) { const f = fixture(t); const source = sdkFixture(f); @@ -156,6 +156,10 @@ test("documented SDK install rejects redirected and shared checkouts before muta writeFileSync(join(sdk, ".git/objects/info/http-alternates"), "https://example.invalid/objects\n"); } else if (kind === "dangling-alternates") { symlinkSync(join(f.root, "missing"), join(sdk, ".git/objects/info/alternates")); + } else if (kind === "shallow-symlink" || kind === "fetch-head-symlink") { + const metadata = kind === "shallow-symlink" ? "shallow" : "FETCH_HEAD"; + rmSync(join(sdk, ".git", metadata), { force: true }); + symlinkSync(join(f.root, `external-${metadata}`), join(sdk, ".git", metadata)); } else writeFileSync(join(sdk, "README.md"), "local edit\n"); } } From 7a9efe56310cf2049d8b3ac79bceed4737aa416c Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:14:25 +0000 Subject: [PATCH 16/37] Treat a storage not-found as indeterminate and pin revocation authorization DEMOS-MAPPING A.2 gives an ordinary storage API not-found the SR-2 disposition indeterminate, and DACS-1 RB-6 makes transport failures and integrity mismatches indeterminate too. readAnchorOutcome now reports every read that yields no readable artifact (404, any non-2xx, a failed or malformed body, a response without an artifact object) as unavailable, so a known verified marker or an owner-anchored candidate withholds its listing instead of letting it be republished active. Adds RB-1/RB-4 vectors for markers whose signature object, algorithm, signer, value, listingId, listingVersion or content hash is wrong, a marker carrying a reason, and a prior binding that must stay with the listing it revoked. malformed-artifacts now clears queued candidates between tests. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/chain.ts | 13 ++-- .../dacs-directory/src/catalog/indexer.ts | 5 +- .../test/malformed-artifacts.test.ts | 8 +++ .../dacs-directory/test/scan-fairness.test.ts | 72 ++++++++++++++++++- .../dacs-directory/test/verification.test.ts | 60 ++++++++++++++++ 5 files changed, 150 insertions(+), 8 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index 092a09e..39bcbcf 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -52,8 +52,14 @@ export async function readAnchorRecord(address: string): Promise= 500 || res.status === 429) return { status: "unavailable" }; const record = await anchorRecordFromResponse(res); - return record ? { status: "found", record } : { status: "absent" }; + return record ? { status: "found", record } : { status: "unavailable" }; } async function anchorRecordFromResponse(res: Response): Promise { diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 496ef23..054ba7c 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -253,8 +253,9 @@ export async function indexRegistration( ) ?? priorUnreadRevocation(prior, verified.contentHash, progress.unread); recordRevocationProgress(verified.contentHash, progress); // SDK readers take a fresh `active` summary without a binding as established - // revocation absence, so a marker the node could not serve must never - // produce one. A published binding stays revoked (above); a verified marker + // revocation absence, so a marker the node could not serve (including a + // storage `not found`, which DEMOS-MAPPING §A.2 makes indeterminate) must + // never produce one. A published binding stays revoked (above); a verified marker // without one, or a candidate the listing owner anchored, withholds the // listing until it can be read. Anyone can anchor other candidates, so an // outage on those cannot withhold it. diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index 7cfe9cb..c008450 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -121,6 +121,14 @@ function resetIndex(registrations: Registration[], generatedAt: number) { schemaVersion: 8, lastSeenTxId: 0, listings: {}, deals: {}, programs: {}, revocations: {}, verifiedRevocations: {}, bundleBindings: {}, bundleBindingOverflow: [], anchorBackfillComplete: true, }); + // Queued candidates from an earlier test would name programs this test's node + // never serves, and a storage `not found` for an owner's candidate withholds. + const db = new Database(join(dataDirectory, "directory.sqlite")); + try { + if (db.prepare("SELECT name FROM sqlite_master WHERE name='revocation_candidates'").get()) { + db.prepare("DELETE FROM revocation_candidates").run(); + } + } finally { db.close(); } } const artifactRow = (at: string) => { const db = new Database(join(dataDirectory, "directory.sqlite"), { readonly: true }); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index f83224b..00e401b 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -69,6 +69,8 @@ let transactions: Obj[] = []; let reads = new Map(); const unavailable = new Set(); const scanOnly = new Set(); +/** Locators the node answers with a fixed response once they exist. */ +const answers = new Map Response>(); let nodeDown = false; globalThis.fetch = async (input, init) => { const path = new URL(String(input)).pathname; @@ -79,6 +81,8 @@ globalThis.fetch = async (input, init) => { const entry = storage[at]; if (!entry) return new Response(null, { status: 404 }); if (unavailable.has(at) || (scanOnly.has(at) && count > 1)) return new Response(null, { status: 503 }); + const answer = answers.get(at); + if (answer) return answer(); return new Response(`{"success":true,"owner":${JSON.stringify(entry.owner ?? ownerOf(seller))},` + `"programName":${JSON.stringify(entry.name ?? "opaque")},"data":${entry.data}}`, { headers: { "content-type": "application/json" } }); } @@ -94,6 +98,7 @@ function chain(programs: Record, written: string[], first reads = new Map(); unavailable.clear(); scanOnly.clear(); + answers.clear(); transactions = written.map((at, index) => memo(firstId + index, at)).reverse(); } const readsOf = (locators: string[]) => locators.reduce((total, at) => total + (reads.get(at) ?? 0), 0); @@ -694,10 +699,12 @@ test("an unreadable verified revocation marker never republishes its listing as assert.equal(summary()?.status, "revoked"); }); -test("an unavailable candidate anchored by the listing owner withholds the listing; another writer's or a vanished one does not", async () => { +test("an unavailable or not-found candidate anchored by the listing owner withholds the listing; another writer's does not", async () => { + // DEMOS-MAPPING §A.2: a storage API `not found` is SR-2 indeterminate, never absence. for (const [writer, after, expected] of [ [outsider, "unavailable", "active"], - [seller, "gone", "active"], + [outsider, "gone", "active"], + [seller, "gone", undefined], [seller, "unavailable", undefined], ] as const) { resetIndex([sellerRegistration, later], 32); @@ -717,6 +724,67 @@ test("an unavailable candidate anchored by the listing owner withholds the listi } }); +test("a published binding carried through an outage stays with the listing it revoked", async () => { + const otherScope = { ...listingScope, listingId: "svc2", serviceId: "svc2" }; + const other = { ...otherScope, signature: await signature(contentHash(otherScope), "dacs-listing:v1:", 1) }; + const otherHash = (await verifyListing(other))!.contentHash; + resetIndex([{ ...sellerRegistration, listingAnchors: [locator(1), locator(2)] }, later], 34); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(2)]: { data: JSON.stringify(other) }, + [locator(60)]: { data: JSON.stringify(marker) }, + }, [locator(1), locator(2), locator(60)]); + await reindex(); + const byId = (id: string) => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller) + ?.listings.find((entry) => entry.listingId === id); + const revoked = byId("svc"); + assert.equal(revoked?.status, "revoked"); + assert.equal(byId("svc2")?.status, "active"); + + // The marker now also stands as a known marker for the other listing, and is unreadable. + const state = store.loadScanState(); + store.saveScanState({ ...state, verifiedRevocations: { ...state.verifiedRevocations, [otherHash]: [locator(60)] } }); + transactions = []; + unavailable.add(locator(60)); + await reindex(); + assert.deepEqual(byId("svc")?.revocation, revoked?.revocation, "the revoked listing keeps its own binding"); + assert.equal(byId("svc2"), undefined, "the other listing never inherits that binding"); +}); + +test("a known verified marker that answers without a readable artifact withholds its listing", async () => { + // DEMOS-MAPPING §A.2 and DACS-1 RB-6: none of these answers is authoritative absence. + const truncated = () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"success":true,')); + controller.error(new Error("connection reset")); + }, + }), { headers: { "content-type": "application/json" } }); + const cases: Array<[string, (at: string) => void]> = [ + ["503", (at) => unavailable.add(at)], + ["404", (at) => answers.set(at, () => Response.json({ success: false, errorCode: "NOT_FOUND" }, { status: 404 }))], + ["deleted", (at) => { delete storage[at]; }], + ["500", (at) => answers.set(at, () => new Response(null, { status: 500 }))], + ["400", (at) => answers.set(at, () => new Response(null, { status: 400 }))], + ["truncated body", (at) => answers.set(at, truncated)], + ["malformed JSON", (at) => answers.set(at, () => new Response('{"success":true,"data":', { status: 200 }))], + ["success false", (at) => answers.set(at, () => Response.json({ success: false }))], + ["non-object data", (at) => answers.set(at, () => Response.json({ success: true, data: [1] }))], + ]; + const summary = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]; + for (const [name, outage] of cases) { + resetIndex([sellerRegistration, later], 40); + chain({ [locator(1)]: { data: JSON.stringify(listing) }, [locator(60)]: { data: JSON.stringify(marker) } }, [locator(1), locator(60)]); + await reindex(); + assert.equal(summary()?.status, "revoked", `${name}: the marker was verified`); + // The published binding is gone; only the verified marker is still known. + store.saveCatalog({ catalogVersion: "1", generatedAt: 41, sellers: [] }); + transactions = []; + outage(locator(60)); + await reindex(); + assert.equal(summary(), undefined, `${name}: the listing is withheld, not republished active`); + } +}); + test("a transient retry is read before rejected retries and the revocation it carries is applied", async () => { resetIndex([sellerRegistration, later], 10); chain({ [locator(1)]: { data: JSON.stringify(listing) }, [locator(60)]: { data: JSON.stringify(marker) } }, [locator(1), locator(60)]); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 98657ea..27c6254 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -594,6 +594,66 @@ test("any valid revocation candidate wins and scanner candidates deduplicate", a assert.equal(isListingRevocationCandidate({ ...valid, listingContentHash: "not-a-hash" }), false); }); +test("RB-1/RB-4: only the listing signer's marker over the exact listing tuple revokes it", async () => { + const listingMessage = Buffer.from(`dacs-listing:v1:${contentHash(listing)}`, "utf8"); + const listingSignature = Buffer.from(await ed25519Sign(listingMessage, privateKeyFromSeed(seed))).toString("hex"); + const verified = await verifyListing({ ...listing, signature: listingSignature }); + assert.ok(verified); + if (!verified) return; + const scopeFor = (overrides: Record = {}) => ({ + listingId: listing.listingId, + listingVersion: listing.listingVersion, + listingContentHash: verified.contentHash, + revokedAt: 1, + ...overrides, + }); + const signed = async (scope: Record, signerSeed = seed, encoding: "hex" | "base64url" = "base64url") => + Buffer.from(await ed25519Sign(Buffer.from(`dacs-revocation:v1:${contentHash(scope)}`, "utf8"), privateKeyFromSeed(signerSeed))) + .toString(encoding); + const markerOf = async (scope: Record, signature: Record = {}) => + ({ ...scope, signature: { algorithm: "ed25519", signer: did, value: await signed(scope), ...signature } }); + const verdict = async (marker: Record) => ({ + verified: await verifyListingRevocation(marker, verified, 1), + published: Boolean(await findValidListingRevocation(["marker"], verified, 1, async () => marker)), + }); + const revokes = { verified: true, published: true }; + const ignored = { verified: false, published: false }; + + const valid = await markerOf(scopeFor()); + assert.deepEqual(await verdict(valid), revokes); + // The optional `reason` is part of the signed scope; it neither blocks discovery nor revocation. + const withReason = await markerOf(scopeFor({ reason: "retired" })); + assert.equal(isListingRevocationCandidate(withReason), true); + assert.deepEqual(await verdict(withReason), revokes); + + const scope = scopeFor(); + const cases: Record> = { + "no signature": { ...scope }, + "null signature": { ...scope, signature: null }, + "string signature": { ...scope, signature: valid.signature.value }, + "array signature": { ...scope, signature: [valid.signature] }, + "sr1-aggregate envelope": { ...valid, signature: { ...valid.signature, algorithm: "sr1-aggregate" } }, + "ecdsa-secp256k1 envelope": { ...valid, signature: { ...valid.signature, algorithm: "ecdsa-secp256k1" } }, + "missing signer": { ...valid, signature: { algorithm: "ed25519", value: valid.signature.value } }, + "non-string value": { ...valid, signature: { ...valid.signature, value: 7 } }, + "undecodable value": { ...valid, signature: { ...valid.signature, value: "AAAA" } }, + "64 zero bytes": { ...valid, signature: { ...valid.signature, value: Buffer.alloc(64).toString("base64url") } }, + // The listing key signed each of these, but the tuple names another listing or version. + "other listingId": await markerOf(scopeFor({ listingId: "other" })), + "other listingVersion": await markerOf(scopeFor({ listingVersion: 2 })), + "other listingContentHash": await markerOf(scopeFor({ listingContentHash: "0".repeat(64) })), + // The listing key signed it, but the envelope names another claim as signer. + "envelope names another signer": await markerOf(scope, { signer: buyerDid }), + // Another claim signed it and names itself. + "signed by another claim": await markerOf(scope, { signer: buyerDid, value: await signed(scope, buyerSeed) }), + // Another claim signed it but the envelope names the listing signer. + "another key under the listing signer": await markerOf(scope, { value: await signed(scope, buyerSeed) }), + }; + for (const [name, marker] of Object.entries(cases)) { + assert.deepEqual(await verdict(marker), ignored, name); + } +}); + test("standalone revocation verifier rejects a signed revokedAt that is not a non-negative safe integer", async () => { const markerSeed = Uint8Array.from(randomBytes(32)); const markerDid = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(markerSeed))).toString("hex")}`; From 5a05e678804876a0cf007bf38df8c73c390d1113 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:14:25 +0000 Subject: [PATCH 17/37] Publish DACS-4 registry rail IDs and keep them in the catalog projection The publisher used phase-handler names (pay-dem, pay-x402) as rail IDs, so readers resolving the DACS-4 9.4.2 registry rejected its Listings as unknown-rail. It now emits demos-native:DEM and x402:default with their registered handlers, accepts the registry grammar on input, and the catalog projection keeps uppercase registry IDs for the 6.3.6 rail filter. rail-registry.test checks the table against an independent copy of the pinned Standard's registry and resolves a /register-built Listing against it. build-listing adds vectors that refuse a transaction for an invalid listing signature and allocate versions per listingId. Co-Authored-By: Claude Opus 5.5 --- .../app/api/dacs/build-listing/route.ts | 3 +- .../dacs-directory/app/register/page.tsx | 2 +- .../src/catalog/listingMetadata.ts | 4 +- .../src/catalog/listingOptions.ts | 23 +++- .../dacs-directory/src/components/labels.ts | 2 + .../dacs-directory/test/build-listing.test.ts | 64 ++++++++++ .../fixtures/dacs4-v0.1-rail-registry.json | 60 +++++++++ .../dacs-directory/test/rail-registry.test.ts | 115 ++++++++++++++++++ 8 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/fixtures/dacs4-v0.1-rail-registry.json create mode 100644 reference-implementations/dacs-directory/test/rail-registry.test.ts 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 acf6d76..fd15961 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 @@ -41,6 +41,7 @@ import { publishableRail, PUBLISHABLE_PRICING_KINDS, PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + RAIL_ID_PATTERN, type PublishablePricingKind, } from "@/src/catalog/listingOptions"; @@ -94,7 +95,7 @@ export async function POST(req: NextRequest) { } if ( !Array.isArray(body.rails) || body.rails.length > 16 || - !body.rails.every((v) => typeof v === "string" && /^[a-z0-9:-]{1,128}$/.test(v)) || + !body.rails.every((v) => typeof v === "string" && v.length <= 128 && RAIL_ID_PATTERN.test(v)) || !Array.isArray(body.delivery) || body.delivery.length > 16 || !body.delivery.every((v) => typeof v === "string" && /^[a-z0-9:-]{1,128}$/.test(v)) ) { diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index d515643..de4ea0c 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -88,7 +88,7 @@ export default function Register() { const [serviceId, setServiceId] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const [rails, setRails] = useState(["pay-dem"]); + const [rails, setRails] = useState([PUBLISHABLE_RAIL_OPTIONS[0].railId]); const [category, setCategory] = useState("services.other"); const [tags, setTags] = useState(""); const [delivery, setDelivery] = useState(DELIVERY_OPTIONS[0].id); diff --git a/reference-implementations/dacs-directory/src/catalog/listingMetadata.ts b/reference-implementations/dacs-directory/src/catalog/listingMetadata.ts index 0e04991..83d5d05 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingMetadata.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingMetadata.ts @@ -32,6 +32,8 @@ export function listingPresentation(scope: Record) { ? rawCategory : "services.other"; const protocolId = /^[a-z0-9:-]{1,128}$/; + // DACS-4 §9.4.2 registry rail IDs carry uppercase asset codes (`demos-native:DEM`). + const railId = /^[A-Za-z0-9._~:-]{1,128}$/; const pipeline = Array.isArray(scope.pipeline) ? scope.pipeline.filter((step): step is Record => Boolean(step) && typeof step === "object" && !Array.isArray(step)) .map((step) => step.kind).filter((kind): kind is string => typeof kind === "string") @@ -60,7 +62,7 @@ export function listingPresentation(scope: Record) { : typeof scope.description === "string" ? scope.description.slice(0, 2000) : "", category, tags: boundedStrings(currentOffering?.tags ?? scope.tags, 16, 64), - rails: boundedStrings(currentOffering ? acceptedRails : scope.supportedPaymentRails, 16, 128, protocolId), + rails: boundedStrings(currentOffering ? acceptedRails : scope.supportedPaymentRails, 16, 128, railId), delivery: currentOffering ? pipeline.filter((kind) => kind.startsWith("deliver-")) : boundedStrings(scope.supportedDelivery, 16, 128, protocolId), diff --git a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts index 24c060d..2876f12 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts @@ -1,6 +1,13 @@ +/** + * Rail IDs are DACS-4 §9.4.2 registry IDs, distinct from the phase handler + * (`phaseKind`) they dispatch to. Every row repeats the pinned Standard's + * v0.1 registry table (DACS-Standard d45c0a2, the dacs-sdk 3904d70 + * `dacs.standardRevision`); test/rail-registry.test.ts checks it against an + * independent copy of that table. + */ export const PUBLISHABLE_RAIL_OPTIONS = [ - { 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: "demos-native:DEM", railVersion: 1, phaseKind: "pay-dem", label: "DEM on Demos", availability: "live" }, + { railId: "x402:default", railVersion: 1, phaseKind: "pay-x402", label: "USDC via x402", availability: "live" }, { railId: "ap2:stripe-paymentintents", railVersion: 1, @@ -10,7 +17,14 @@ export const PUBLISHABLE_RAIL_OPTIONS = [ }, ] as const; -/** DACS-4 PA-1 definitions authenticated by this exact reviewed app release. */ +/** + * DACS-4 PA-1 definitions carried in this exact reviewed app release. + * + * The pinned Standard publishes the v0.1 registry as a table, not as + * dacs-rail:v1-signed definitions, so `signatureValid` here records that the + * release itself is the in-code authority (LRR-6 `pa1-in-code`). It is not a + * signature check and never a canonically anchored result. + */ export const PUBLISHER_IN_CODE_RAIL_DEFINITIONS = PUBLISHABLE_RAIL_OPTIONS.map((rail) => ({ railId: rail.railId, railVersion: rail.railVersion, @@ -22,6 +36,9 @@ export const PUBLISHER_IN_CODE_RAIL_DEFINITIONS = PUBLISHABLE_RAIL_OPTIONS.map(( export const PUBLISHABLE_PRICING_KINDS = ["fixed", "negotiable", "auction", "metered"] as const; export type PublishablePricingKind = (typeof PUBLISHABLE_PRICING_KINDS)[number]; +/** DACS-4 §9.4.2 registry rail IDs: an exact `:` token. */ +export const RAIL_ID_PATTERN = /^[A-Za-z0-9._~-]+(?::[A-Za-z0-9._~-]+)+$/; + export function publishableRail(railId: string) { return PUBLISHABLE_RAIL_OPTIONS.find((option) => option.railId === railId); } diff --git a/reference-implementations/dacs-directory/src/components/labels.ts b/reference-implementations/dacs-directory/src/components/labels.ts index 65e4abd..39bf818 100644 --- a/reference-implementations/dacs-directory/src/components/labels.ts +++ b/reference-implementations/dacs-directory/src/components/labels.ts @@ -1,5 +1,7 @@ /** Human labels for the spec's kebab-case rail / delivery / negotiation ids. */ export const RAIL_LABELS: Record = { + "demos-native:DEM": "DEM", + "x402:default": "USDC · x402", "pay-dem": "DEM", "pay-x402": "USDC · x402", "ap2:stripe-paymentintents": "Stripe PaymentIntents · AP2", diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 5d6bf7a..836f536 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -361,3 +361,67 @@ test("publisher refuses a new write when existing-publication lookup is indeterm globalThis.fetch = originalFetch; } }); + +test("publisher returns no transaction for a listing signature that does not verify", async () => { + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const otherKey = privateKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 13))); + const hexSig = (message: string, key = privateKey) => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), key)).toString("hex"); + const finalInput = { + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature: hexSig(identity.identityMessage), + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => init?.method === "POST" + ? Response.json({ result: 200, response: JSON.parse(String(init.body)).params?.[0]?.message === "searchStoragePrograms" ? [] : 0 }) + : new Response(null, { status: 404 }); + try { + const preview = await (await POST(request(finalInput))).json() as { message: string; tx: unknown }; + assert.equal(preview.tx, null); + for (const [name, listingSignature] of Object.entries({ + "garbage 64 bytes": "ab".repeat(64), + "signature over another message": hexSig(`${preview.message}0`), + "signature by another key": hexSig(preview.message, otherKey), + })) { + const response = await POST(request({ ...finalInput, listingSignature })); + assert.equal(response.status, 400, name); + const body = await response.json() as { tx?: unknown }; + assert.equal(body.tx, undefined, `${name}: no broadcastable transaction`); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("LP-3 listing versions are allocated per listingId", async () => { + const store = await import("../src/catalog/store.js"); + const summary = (listingId: string, version: number) => ({ + listingId, version, contentHash: "0".repeat(64), anchor: { kind: "storage-program", locator: `stor-${"0".repeat(40)}` }, + seller: { primaryClaim: claim }, offering: { title: "", description: "", category: "services.other", tags: [] }, + pricing: {}, status: "active", + }); + store.saveCatalog({ + catalogVersion: "1", + generatedAt: 1, + sellers: [{ primaryClaim: claim, listings: [summary("another-listing", 5), summary(base.serviceId, 2)] }], + } as never); + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => init?.method === "POST" + ? Response.json({ result: 200, response: JSON.parse(String(init.body)).params?.[0]?.message === "searchStoragePrograms" ? [] : 0 }) + : new Response(null, { status: 404 }); + try { + const preview = async (serviceId: string) => (await (await POST(request({ + ...base, + serviceId, + identityPresentedAt: identity.identityPresentedAt, + identitySignature: Buffer.from(ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey)).toString("hex"), + }))).json() as { listing: { listingVersion: number } }).listing.listingVersion; + assert.equal(await preview(base.serviceId), 3, "the next version of this listingId"); + assert.equal(await preview("fresh-listing"), 1, "another listingId's versions are not inherited"); + } finally { + globalThis.fetch = originalFetch; + store.saveCatalog({ catalogVersion: "1", generatedAt: 2, sellers: [] }); + } +}); diff --git a/reference-implementations/dacs-directory/test/fixtures/dacs4-v0.1-rail-registry.json b/reference-implementations/dacs-directory/test/fixtures/dacs4-v0.1-rail-registry.json new file mode 100644 index 0000000..c867f6b --- /dev/null +++ b/reference-implementations/dacs-directory/test/fixtures/dacs4-v0.1-rail-registry.json @@ -0,0 +1,60 @@ +{ + "source": "DACS-Standard spec/DACS-4-SETTLE.md §9.4.2 v0.1 registry contents", + "standardRevision": "d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20", + "lines": "205-217", + "note": "Copied from the pinned Standard table (the dacs-sdk 3904d70 dacs.standardRevision); not derived from the Directory's own rail table.", + "rails": [ + { + "railId": "evm-erc20:1:USDC", + "phaseHandler": "pay-evm-erc20" + }, + { + "railId": "evm-erc20:8453:USDC", + "phaseHandler": "pay-evm-erc20" + }, + { + "railId": "evm-erc20:42161:USDC", + "phaseHandler": "pay-evm-erc20" + }, + { + "railId": "evm-erc20:137:USDC", + "phaseHandler": "pay-evm-erc20" + }, + { + "railId": "solana-spl:mainnet:USDC", + "phaseHandler": "pay-solana-spl" + }, + { + "railId": "solana-spl:mainnet:USDT", + "phaseHandler": "pay-solana-spl" + }, + { + "railId": "cross-chain-htlc:USDC", + "phaseHandler": "pay-cross-chain-htlc" + }, + { + "railId": "cross-chain-liquidity-tank:USDC", + "phaseHandler": "pay-cross-chain-liquidity-tank" + }, + { + "railId": "demos-native:DEM", + "phaseHandler": "pay-dem" + }, + { + "railId": "ap2:visa-direct", + "phaseHandler": "pay-ap2" + }, + { + "railId": "ap2:mastercard-send", + "phaseHandler": "pay-ap2" + }, + { + "railId": "ap2:stripe-paymentintents", + "phaseHandler": "pay-ap2" + }, + { + "railId": "x402:default", + "phaseHandler": "pay-x402" + } + ] +} diff --git a/reference-implementations/dacs-directory/test/rail-registry.test.ts b/reference-implementations/dacs-directory/test/rail-registry.test.ts new file mode 100644 index 0000000..ec8f41b --- /dev/null +++ b/reference-implementations/dacs-directory/test/rail-registry.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; + +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { listingPresentation } from "../src/catalog/listingMetadata.js"; +import { PUBLISHABLE_RAIL_OPTIONS, RAIL_ID_PATTERN } from "../src/catalog/listingOptions.js"; +import { resolveListingRails } from "../src/sdkListingValidation.js"; + +const dataDir = mkdtempSync(join(tmpdir(), "dacs-rail-registry-")); +process.env.DACS_DIRECTORY_DATA = join(dataDir, "directory.sqlite"); +const { POST } = await import("../app/api/dacs/build-listing/route.js"); +test.after(() => rmSync(dataDir, { recursive: true, force: true })); + +// An independent copy of DACS-4 §9.4.2 at the pinned Standard revision; the +// Directory's own table is never used as the oracle here. +const registry = JSON.parse(readFileSync(new URL("./fixtures/dacs4-v0.1-rail-registry.json", import.meta.url), "utf8")) as { + standardRevision: string; + rails: Array<{ railId: string; phaseHandler: string }>; +}; +const standardDefinitions = registry.rails.map((rail) => ({ + railId: rail.railId, + railVersion: 1, + phaseHandler: rail.phaseHandler, + governanceAnchoring: "in-code", + signatureValid: true, +})); +const resolveAgainstStandard = (listing: { pipeline: Array<{ kind: string; parameters?: Record }>; acceptedRails: unknown[] }) => + resolveListingRails({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + payPhases: listing.pipeline.filter((phase) => phase.kind.startsWith("pay-")) + .map((phase) => ({ kind: phase.kind, rail: phase.parameters?.rail })), + acceptedRails: listing.acceptedRails as never, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: standardDefinitions, + }); + +test("every publishable rail is a DACS-4 §9.4.2 registry ID with its registered phase handler", () => { + assert.equal(registry.standardRevision, "d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20"); + assert.equal(registry.rails.length, 13); + for (const option of PUBLISHABLE_RAIL_OPTIONS) { + const entry = registry.rails.find((rail) => rail.railId === option.railId); + assert.ok(entry, `${option.railId} is in the registry`); + assert.equal(option.phaseKind, entry.phaseHandler, option.railId); + } + for (const rail of registry.rails) assert.match(rail.railId, RAIL_ID_PATTERN, rail.railId); + for (const handler of ["pay-dem", "pay-x402", "pay-ap2"]) { + assert.doesNotMatch(handler, RAIL_ID_PATTERN, `${handler} is a phase handler, not a rail ID`); + } +}); + +test("a /register-built Listing verifies under a reader using the Standard registry", async () => { + const seed = Uint8Array.from(Buffer.alloc(32, 31)); + const privateKey = privateKeyFromSeed(seed); + const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; + const request = (body: Record) => new NextRequest("https://directory.example/api/dacs/build-listing", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => init?.method === "POST" + ? Response.json({ result: 200, response: JSON.parse(String(init.body)).params?.[0]?.message === "searchStoragePrograms" ? [] : 0 }) + : new Response(null, { status: 404 }); + try { + for (const option of PUBLISHABLE_RAIL_OPTIONS) { + const base = { + claim, + serviceId: `svc-${option.phaseKind}`, + name: "Registry rail service", + description: "Settles through a registry rail.", + rails: [option.railId], + delivery: ["deliver-storage-program"], + pricing: { kind: "fixed", amount: "0.01", currency: option.phaseKind === "pay-dem" ? "DEM" : "USD" }, + }; + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const response = await POST(request({ + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature: Buffer.from(ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey)).toString("hex"), + })); + assert.equal(response.status, 200, option.railId); + const preview = await response.json() as { + listing: { pipeline: Array<{ kind: string; parameters?: Record }>; acceptedRails: Array<{ railId: string }> }; + railResolution: { disposition: string; authorityBasis: string }; + }; + assert.deepEqual(preview.listing.acceptedRails, [{ railId: option.railId }]); + assert.deepEqual(preview.listing.pipeline.find((phase) => phase.kind === option.phaseKind)?.parameters, { rail: option.railId }); + assert.equal(preview.railResolution.authorityBasis, "pa1-in-code"); + const independent = resolveAgainstStandard(preview.listing); + assert.equal(independent.disposition, "verified", `${option.railId}: ${independent.reason}`); + // The handler name the Directory used to emit as a rail ID is not in the registry. + const handlerAsRail = resolveAgainstStandard({ + pipeline: [{ kind: option.phaseKind, parameters: { rail: option.phaseKind } }], + acceptedRails: [{ railId: option.phaseKind }], + }); + assert.equal(handlerAsRail.disposition, "rejected", option.phaseKind); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("the catalog projection keeps every registry rail ID for the §6.3.6 rail filter", () => { + const presentation = listingPresentation({ + offering: { title: "Rails", description: "", category: "services.other" }, + pipeline: [], + acceptedRails: registry.rails.map((rail) => ({ railId: rail.railId })), + }); + assert.deepEqual(presentation.rails, registry.rails.map((rail) => rail.railId)); +}); From d21b68e8dfc0b41d2e69be4e463f4dafd85426b1 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:14:25 +0000 Subject: [PATCH 18/37] Match the pinned SDK on bundle hashing, bindings and Demos write reads - hash the identity presentation over the SDK's identityBundleHash - publish RevocationBindings with the exact presentedBy bytes - fall back to block one when a node answers block zero with an error, read a known transactionRef by hash, and normalise JSON-string signatures so receipts carry the SDK's signed-transaction proof hash - require the node's validator-signature shape and a 64-hex block hash; validator signatures and quorum are still not verified - use Node's IPv6 zone grammar in the browser isIP shim Tests use node2-shaped fixtures and pin the presentation-kind, injected key-resolver and identity-comparison branches. Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/listingFinality.ts | 75 ++++++++++++++-- .../src/catalog/listingVerification.ts | 20 +++-- .../dacs-directory/src/shims/node-net.ts | 5 +- .../dacs-directory/test/browser-shims.test.ts | 2 +- .../test/confirm-listing.test.ts | 87 +++++++++++++++++-- .../test/listing-publication-recovery.test.ts | 8 ++ .../test/listing-verification.test.ts | 55 +++++++++++- .../test/primary-claim-key.test.ts | 51 +++++++++++ .../test/sdk-reader-verifiers.test.ts | 17 +++- 9 files changed, 292 insertions(+), 28 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts index 01971d9..ffca736 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -41,26 +41,67 @@ async function nodeCall(message: string, data: Record): Promise return body.response; } +/** + * Mirrors the pinned SDK's DemosAdapter.resolveChainIdentity: deployed nodes + * (node2 0.9.8 among them) answer block zero with `result: 400` / `"error"`, + * so a failed or hashless genesis read falls back to block one's predecessor. + */ async function chainIdentity(): Promise { - const genesis = record(await nodeCall("getBlockByNumber", { blockNumber: 0 })); + const genesis = record(await nodeCall("getBlockByNumber", { blockNumber: 0 }).catch(() => null)); const genesisHash = typeof genesis?.hash === "string" ? genesis.hash.trim().toLowerCase() : ""; - if (genesisHash && (genesis?.number === undefined || genesis.number === 0)) return genesisHash; + const genesisNumber = genesis?.number ?? genesis?.id; + if (genesisHash && (genesisNumber === undefined || genesisNumber === 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) { + if ( + !first || first.status !== "confirmed" || first.number !== 1 || + typeof first.hash !== "string" || !first.hash.trim() || !previousHash + ) { throw new Error("Demos genesis block has no stable chain identity"); } return previousHash; } +/** node2's getTransactions rows carry `content` and `signature` as JSON strings; getTxByHash carries objects. */ +function normalizedTransaction(tx: Record): Record { + const signature = typeof tx.signature === "string" ? parsedRecord(tx.signature) : null; + return { + ...tx, + content: parsedRecord(tx.content) ?? tx.content, + ...(signature ? { signature } : {}), + }; +} + +/** The SDK re-reads a known write by hash; the newest-history scan is only a fallback. */ +async function writeByHash( + anchorAddress: string, + listingContentHash: string, + expectedTxRef: string, +): Promise | null> { + const txRef = expectedTxRef.toLowerCase(); + const tx = record(await nodeCall("getTxByHash", { hash: txRef }).catch(() => null)); + const content = parsedRecord(tx?.content); + if (!tx || !content) return null; + const candidate = storageWriteCandidate({ ...tx, type: tx.type ?? content.type, to: tx.to ?? content.to, content }); + return candidate?.locator === anchorAddress && + candidate.contentHash === listingContentHash && + candidate.transactionHash === txRef + ? normalizedTransaction(tx) + : null; +} + async function findCanonicalWrite( anchorAddress: string, listingContentHash: string, expectedTxRef?: string, ): Promise | null> { + if (expectedTxRef) { + const known = await writeByHash(anchorAddress, listingContentHash, expectedTxRef); + if (known) return known; + } let cursor: number | "latest" = "latest"; let scanned = 0; while (scanned < MAX_TRANSACTIONS) { @@ -77,7 +118,7 @@ async function findCanonicalWrite( candidate.contentHash === listingContentHash && (!expectedTxRef || candidate.transactionHash === expectedTxRef.toLowerCase()) ) { - return { ...tx, content: parsedRecord(tx.content) ?? tx.content }; + return normalizedTransaction(tx); } } scanned += page.length; @@ -128,9 +169,10 @@ export function listingAnchorReceiptRejection( } /** - * Establish a portable finalized Demos AnchorReceipt from canonical transaction, - * BFT-confirmed block, and exact native readback. Read visibility alone never - * returns `finalized`. + * Establish a finalized Demos AnchorReceipt from the canonical transaction, + * the block the configured node reports as BFT-confirmed, and exact native + * readback. Read visibility alone never returns `finalized`. Consensus + * signatures are not independently verified (see validatorSignatureShape). */ export async function finalizedListingAnchorReceipt(input: { anchorAddress: string; @@ -170,9 +212,9 @@ export async function finalizedListingAnchorReceipt(input: { : NaN; if ( !block || block.status !== "confirmed" || block.number !== blockNumber || - typeof block.hash !== "string" || !block.hash || + typeof block.hash !== "string" || !/^[0-9a-f]{64}$/.test(block.hash) || !Number.isSafeInteger(blockTimestamp) || !orderedTransactions.includes(txRef) || - block.validation_data === undefined + !validatorSignatureShape(block.validation_data) ) return { status: "pending", reason: "canonical block is not BFT-confirmed" }; const payload = Array.isArray(txContent.data) ? record(txContent.data[1]) : null; @@ -230,6 +272,21 @@ export async function finalizedListingAnchorReceipt(input: { } } +/** + * The node's `validation_data` shape: a non-empty map from validator public + * key to Ed25519 block signature. This refuses placeholder or arbitrary + * objects, but it is a shape check only. The signatures, the eligible + * validator pool and the 2/3+1 quorum are not verified here, nor by the + * pinned SDK's own Demos adapter, so a `finalized` receipt rests on the + * configured Demos node's report. + */ +function validatorSignatureShape(value: unknown): boolean { + const signatures = record(record(value)?.signatures); + const entries = signatures ? Object.entries(signatures) : []; + return entries.length > 0 && entries.every(([key, signature]) => + /^0x[0-9a-f]{64}$/i.test(key) && typeof signature === "string" && /^0x[0-9a-f]{128}$/i.test(signature)); +} + 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/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index da2251f..4375959 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -6,9 +6,11 @@ import { isVerificationMethod, readListingArtifact, verifyComponentSignature, + type IdentityBundle, type LegacyMvpListing, type Listing, } from "@kynesyslabs/dacs/artifacts"; +import { identityBundleHash } from "@kynesyslabs/dacs/identity"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, @@ -118,9 +120,12 @@ async function verifyIdentityPresentation( const signature = presentation.signatures.map(record).find((item) => typeof item?.ref === "string" && canonicalSigningIdentity(item.ref) === sellerIdentity); if (!signature) return null; - const bundleScope = { ...identity }; - delete bundleScope.presentation; - const message = Buffer.from(`dacs-bundle-presentation:v1:${contentHash(bundleScope)}`, "utf8"); + // §6.3.2 bundle_hash omits only `presentation`; a top-level `signature` + // member is bundle content, unlike in contentHash. + const message = Buffer.from( + `dacs-bundle-presentation:v1:${identityBundleHash(identity as unknown as IdentityBundle)}`, + "utf8", + ); return verifyEd25519(message, sellerClaim, signature.signature, resolveKey); } @@ -333,12 +338,17 @@ export async function findValidListingRevocation( continue; } const scope = stripSignature(candidate); + // RB-2: SDK readers require the exact `presentedBy` bytes, including any + // CF-3 advisory parameters, and the CF-4 address derived from them. + const sellerPrimaryClaim = listing.profile === "dacs-v0.1" + ? listing.listing.seller.identity.presentedBy + : listing.sellerClaim; return { - sellerPrimaryClaim: listing.sellerClaim, + sellerPrimaryClaim, listingId: String(scope.listingId), listingVersion: expectedVersion, listingContentHash: listing.contentHash, - logicalAddress: revocationLogicalAddress(listing.sellerClaim, String(scope.listingId), expectedVersion), + logicalAddress: revocationLogicalAddress(sellerPrimaryClaim, String(scope.listingId), expectedVersion), markerAnchor: { kind: "storage-program", locator: ref }, markerContentHash: contentHash(scope), }; diff --git a/reference-implementations/dacs-directory/src/shims/node-net.ts b/reference-implementations/dacs-directory/src/shims/node-net.ts index 1c5a8bd..b25f64f 100644 --- a/reference-implementations/dacs-directory/src/shims/node-net.ts +++ b/reference-implementations/dacs-directory/src/shims/node-net.ts @@ -12,10 +12,11 @@ export function isIPv4(value: string): boolean { export function isIPv6(value: string): boolean { if (typeof value !== "string" || !value.includes(":")) return false; - // Node accepts a scope zone suffix; the WHATWG host parser does not. + // Node accepts a scope zone suffix (`%[0-9a-zA-Z-.:]+` in lib/internal/net.js); + // the WHATWG host parser does not. const zone = value.indexOf("%"); const address = zone < 0 ? value : value.slice(0, zone); - if (zone >= 0 && !/^%[^%\s]+$/.test(value.slice(zone))) return false; + if (zone >= 0 && !/^%[0-9a-zA-Z.:-]+$/.test(value.slice(zone))) return false; if (/[^0-9A-Fa-f:.]/.test(address)) return false; try { new URL(`http://[${address}]/`); diff --git a/reference-implementations/dacs-directory/test/browser-shims.test.ts b/reference-implementations/dacs-directory/test/browser-shims.test.ts index 081a0b4..7acbc14 100644 --- a/reference-implementations/dacs-directory/test/browser-shims.test.ts +++ b/reference-implementations/dacs-directory/test/browser-shims.test.ts @@ -13,7 +13,7 @@ const HOSTS = [ "example.com", "agent.example.com", "xn--bcher-kva.example", "xn--zz.example", "xn--a.example", "a-b.example", "-a.example", "a..b", "localhost", "123.example", "1.2.3.4", "1.2.3", "01.2.3.4", "0x7f.1", "4294967295", "256.1.1.1", "1.2.3.4.5", "example.com.", "EXAMPLE.com", "a".repeat(64) + ".example", - "a_b.example", "", "fe80::1", "::1", "1:2:3:4:5:6:7:8", "::ffff:1.2.3.4", "fe80::1%eth0", "1::2::3", ":::", "g::1", + "a_b.example", "", "fe80::1", "::1", "1:2:3:4:5:6:7:8", "::ffff:1.2.3.4", "fe80::1%eth0", "fe80::1%en0/1", "fe80::1%a_b", "fe80::1%en0.5:x-y", "1::2::3", ":::", "g::1", ]; test("the browser isIP shim agrees with node:net", () => { diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 8f80be2..9dfc3b9 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -7,6 +7,9 @@ import { canonicalize, contentHash, listingAddress, sha256Hex } from "@kynesysla import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; const { POST } = await import("../app/api/dacs/confirm-listing/route.js"); +const { decodeDemosAnchorReceiptProof, demosSignedTransactionProofHash } = await import( + "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js" +); const privateKey = privateKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))); const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))))).toString("hex"); @@ -102,8 +105,19 @@ const transaction = { timestamp: 1_786_360_000_000, transaction_fee: { network_fee: 0, rpc_fee: 0, additional_fee: 0, rpc_address: null }, }, - signature: "signed-envelope", + signature: { type: "ed25519", data: `0x${"11".repeat(64)}` }, }; +// node2 0.9.8 shapes: getTransactions rows carry `content` and `signature` as +// JSON strings; getTxByHash carries objects and no top-level type/to. +type NodeTransaction = Record & { content: unknown; signature: unknown }; +const historyRow = (tx: NodeTransaction) => ({ + ...tx, + content: JSON.stringify(tx.content), + signature: JSON.stringify(tx.signature), +}); +const byHashRow = ({ id: _id, type: _type, to: _to, ...tx }: NodeTransaction) => ({ ...tx, ed25519_signature: null }); +const blockHash = "ef".repeat(32); +const chainId = "83".repeat(32); const coordinates = { anchorAddress, programName, @@ -122,11 +136,13 @@ function stubChain(overrides: { nativeMetadata?: Record; transactionMetadata?: Record; block?: Record; -} = {}): void { + history?: boolean; +} = {}): string[] { const write = transaction.content.data[1] as Record; const written = overrides.transactionMetadata ? { ...transaction, content: { ...transaction.content, data: ["storageProgram", { ...write, metadata: overrides.transactionMetadata }] } } : transaction; + const calls: string[] = []; globalThis.fetch = async (_input, init) => { if (init?.method !== "POST") { return Response.json({ @@ -136,31 +152,44 @@ function stubChain(overrides: { programName, storageAddress: anchorAddress, metadata: overrides.nativeMetadata ?? 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: [written] }); + calls.push(call.message); + if (call.message === "getTransactions") { + return Response.json({ result: 200, response: overrides.history === false ? [] : [historyRow(written)] }); + } + if (call.message === "getTxByHash") { + return call.data.hash === transactionHash + ? Response.json({ result: 200, response: byHashRow(written) }) + : Response.json({ result: 404, response: "error" }); + } if (call.message === "getBlockByNumber" && call.data.blockNumber === 5) { return Response.json({ result: 200, response: { status: "confirmed", number: 5, - hash: "block-5", + hash: blockHash, content: { timestamp: 1_786_360_000, ordered_transactions: [transactionHash] }, - validation_data: { validators: ["fixture-validator"], quorum: 1 }, + validation_data: { signatures: { [`0x${"aa".repeat(32)}`]: `0x${"bb".repeat(64)}` } }, ...overrides.block, }, }); } if (call.message === "getBlockByNumber" && call.data.blockNumber === 0) { - return Response.json({ result: 200, response: { number: 0, hash: "fixture-genesis" } }); + return Response.json({ result: 400, response: "error", extra: "Block number not provided" }); + } + if (call.message === "getBlockByNumber" && call.data.blockNumber === 1) { + return Response.json({ + result: 200, + response: { id: 2, number: 1, status: "confirmed", hash: "12".repeat(32), content: { previousHash: chainId, timestamp: 1 } }, + }); } return Response.json({ result: 500, response: null }); }; + return calls; } test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { @@ -185,6 +214,42 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has assert.equal(receipt.nativeAddress, anchorAddress); assert.equal(receipt.contentHash, listingHash); assert.deepEqual(receipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); + // node2 serves block timestamps in seconds; the receipt carries milliseconds. + assert.deepEqual(receipt.blockRef, { id: blockHash, height: "5", timestamp: 1_786_360_000_000 }); + const proof = decodeDemosAnchorReceiptProof(receipt as never); + assert.equal(proof.chainIdentity, chainId, "block zero answers `error`, so block one's predecessor names the chain"); + // Portable: the SDK verifier re-reads getTxByHash, whose signature is an object. + assert.equal(proof.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction))); + assert.equal(proof.nativeRead.metadataHash, sha256Hex(canonicalize(metadata))); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation reads a browser-supplied transactionRef by hash", async () => { + const originalFetch = globalThis.fetch; + try { + for (const transactionRef of [transactionHash, transactionHash.toUpperCase()]) { + // The write is outside the newest-history window; only the by-hash read finds it. + const calls = stubChain({ history: false }); + const response = await POST(request({ ...coordinates, transactionRef })); + assert.equal(response.status, 200, transactionRef); + const body = await response.json() as { state: string; anchorReceipt: Record }; + assert.equal(body.state, "finalized-and-verified"); + assert.deepEqual(body.anchorReceipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); + assert.equal( + decodeDemosAnchorReceiptProof(body.anchorReceipt as never).signedTransactionHash, + demosSignedTransactionProofHash(byHashRow(transaction)), + ); + assert.ok(calls.includes("getTxByHash")); + } + // Another transaction never stands in for the browser's own write. + stubChain(); + const other = await POST(request({ ...coordinates, transactionRef: "ef".repeat(32) })); + assert.equal(other.status, 202); + assert.equal((await other.json() as { state: string }).state, "finality-pending"); + const malformed = await POST(request({ ...coordinates, transactionRef: "not-a-hash" })); + assert.equal(malformed.status, 400); } finally { globalThis.fetch = originalFetch; } @@ -197,6 +262,12 @@ test("listing confirmation never registers before the write is BFT-final", async { status: "pending" }, { content: { timestamp: 1_786_360_000, ordered_transactions: [] } }, { validation_data: undefined }, + // Placeholder proofs and block references that the node never serves. + { validation_data: { validators: ["fixture-validator"], quorum: 1 } }, + { validation_data: { signatures: {} } }, + { validation_data: { signatures: { "fixture-validator": "signed" } } }, + { hash: "block-5" }, + { hash: "" }, ]) { stubChain({ block }); const response = await POST(request(coordinates)); diff --git a/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts b/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts index b5bc1aa..66887a0 100644 --- a/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts +++ b/reference-implementations/dacs-directory/test/listing-publication-recovery.test.ts @@ -56,6 +56,14 @@ test("listing publication recovery fails closed on malformed or cross-bound stat assert.equal(parsePendingListingPublication({ ...pending, transaction: [] }), null); }); +test("a recovery record saved after broadcast keeps its transactionRef", () => { + const confirming = { ...pending, transactionRef: "9a".repeat(32), stage: "confirming" as const }; + assert.deepEqual(parsePendingListingPublication(confirming), confirming); + for (const transactionRef of ["9A".repeat(32), "9a".repeat(31), 7]) { + assert.equal(parsePendingListingPublication({ ...confirming, transactionRef }), null, String(transactionRef)); + } +}); + test("storage failures refuse unsafe publication persistence", () => { const brokenWrite = { setItem: () => { throw new Error("quota"); } }; const brokenRead = { getItem: () => "{" }; diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index c0c9630..76082a9 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -4,8 +4,16 @@ import test from "node:test"; import { isListing } from "@kynesyslabs/dacs/artifacts"; import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { identityBundleHash } from "@kynesyslabs/dacs/identity"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { verifyListing, verifyListingResult } from "../src/catalog/listingVerification.js"; +import { + findValidListingRevocation, + revocationLogicalAddress, + verifyListing, + verifyListingResult, +} from "../src/catalog/listingVerification.js"; +import { sdkListingSignatureVerifier } from "../src/catalog/sdkReaderVerifiers.js"; +import { checkListingRevocation } from "../vendor/dacs-sdk/dist/agent/listingValidation.js"; const seed = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); const privateKey = privateKeyFromSeed(seed); @@ -292,3 +300,48 @@ test("verifyListing accepts normative metered pricing bound to an AP2 rail", asy acceptedRails: [{ railId: "ap2:stripe-paymentintents" }], })), null); }); + +test("RB-2: a published revocation binding carries the exact presentedBy bytes an SDK reader compares", async () => { + const presentedBy = `${claim}?channel=directory`; + const listing = signedCurrentListing({}, presentedBy); + const verified = await verifyListing(listing); + assert.ok(verified); + const scope = { listingId: "sig5-preserve-unknown", listingVersion: 1, listingContentHash: verified.contentHash, revokedAt: 1 }; + // RB-1: the marker signer repeats the listing signer exactly. + const marker = { ...scope, signature: { algorithm: "ed25519", signer: presentedBy, value: signMessage(`dacs-revocation:v1:${contentHash(scope)}`) } }; + const binding = await findValidListingRevocation(["marker"], verified, 1, async () => marker); + assert.ok(binding); + assert.equal(binding.sellerPrimaryClaim, presentedBy); + assert.equal(binding.logicalAddress, revocationLogicalAddress(presentedBy, "sig5-preserve-unknown", 1)); + const sdk = await checkListingRevocation(listing as never, verified.contentHash, { + nowMs: () => 2, + surfaces: [{ kind: "catalog", status: "revoked", catalogObservedAt: 2, binding }], + readMarker: async () => marker, + verifyMarkerSignature: sdkListingSignatureVerifier(), + }); + assert.equal(sdk.disposition, "revoked", JSON.stringify(sdk)); +}); + +test("the identity presentation signs the §6.3.2 bundle_hash, which keeps a top-level signature member", async () => { + const listingWith = (presentationHash: (bundle: Record) => string) => { + const identity: Record = { + bundleVersion: "1", + presentedBy: claim, + presentedAt: 1, + claims: [{ ref: claim, kind: "signing-key" }], + signature: "bundle content, not a signature envelope", + }; + identity.presentation = { + kind: "per-claim", + signatures: [{ ref: claim, signature: signMessage(`dacs-bundle-presentation:v1:${presentationHash(identity)}`) }], + }; + const { signature: _signature, ...scope } = signedCurrentListing(); + const signedScope = { ...scope, seller: { ...(scope.seller as Record), identity } }; + return { + ...signedScope, + signature: { algorithm: "ed25519", signer: claim, value: signMessage(`dacs-listing:v1:${contentHash(signedScope)}`) }, + }; + }; + assert.ok(await verifyListing(listingWith(identityBundleHash as never)), "SDK identityBundleHash"); + assert.equal(await verifyListing(listingWith(contentHash)), null, "contentHash strips the signature member"); +}); diff --git a/reference-implementations/dacs-directory/test/primary-claim-key.test.ts b/reference-implementations/dacs-directory/test/primary-claim-key.test.ts index 5f1c117..a437a51 100644 --- a/reference-implementations/dacs-directory/test/primary-claim-key.test.ts +++ b/reference-implementations/dacs-directory/test/primary-claim-key.test.ts @@ -11,7 +11,9 @@ import { import { resolveDemosPrimaryClaimKey, resolvePrimaryClaimKey, + sameResolvedPrimaryClaim, verifyPrimaryClaimSignature, + verifyResolvedPrimaryClaimSignature, type ResolvePrimaryClaimKey, } from "../src/catalog/primaryClaimKey.js"; import { registrationMessage, verifyOwnerSignature } from "../src/catalog/registrationSig.js"; @@ -88,3 +90,52 @@ test("registration owner verification rejects a valid signature from an unknown assert.equal(await verifyOwnerSignature(registration), false); }); + +test("every malformed field of an injected resolver result is refused on its own", async () => { + const valid = { canonicalClaim, algorithm: "ed25519" as const, publicKey: Uint8Array.from(publicKey) }; + const resolverFor = (result: unknown): ResolvePrimaryClaimKey => async () => result as never; + assert.deepEqual(await resolvePrimaryClaimKey(canonicalClaim, "ed25519", resolverFor(valid)), valid); + const cases: Record = { + "non-string claim": [7, "ed25519", valid], + "non-string algorithm": [canonicalClaim, 7, valid], + "null result": [canonicalClaim, "ed25519", null], + "non-ed25519 result": [canonicalClaim, "ed25519", { ...valid, algorithm: "secp256k1" }], + "result algorithm differs from the request": [canonicalClaim, "ecdsa-secp256k1", valid], + "non-string canonicalClaim": [canonicalClaim, "ed25519", { ...valid, canonicalClaim: 7 }], + "empty canonicalClaim": [canonicalClaim, "ed25519", { ...valid, canonicalClaim: "" }], + "array key": [canonicalClaim, "ed25519", { ...valid, publicKey: Array.from(publicKey) }], + "31-byte key": [canonicalClaim, "ed25519", { ...valid, publicKey: new Uint8Array(31) }], + "33-byte key": [canonicalClaim, "ed25519", { ...valid, publicKey: new Uint8Array(33) }], + }; + for (const [name, [claim, algorithm, result]] of Object.entries(cases)) { + assert.equal(await resolvePrimaryClaimKey(claim, algorithm, resolverFor(result)), null, name); + } + const throwing: ResolvePrimaryClaimKey = async () => { throw new Error("resolver offline"); }; + assert.equal(await resolvePrimaryClaimKey(canonicalClaim, "ed25519", throwing), null); +}); + +test("signature verification fails closed on crypto exceptions", async () => { + const message = Buffer.from("crypto exception boundary", "utf8"); + const signature = await ed25519Sign(message, privateKey); + const resolved = { canonicalClaim, algorithm: "ed25519" as const, publicKey: Uint8Array.from(publicKey) }; + assert.equal(verifyResolvedPrimaryClaimSignature(message, signature, resolved), true); + assert.equal(verifyResolvedPrimaryClaimSignature(message, signature, { ...resolved, publicKey: new Uint8Array(31) }), false); + assert.equal(verifyResolvedPrimaryClaimSignature(message, signature.slice(0, 63), resolved), false); +}); + +test("two resolved identities are the same only when every field matches", () => { + const left = { canonicalClaim, algorithm: "ed25519" as const, publicKey: Uint8Array.from(publicKey) }; + assert.equal(sameResolvedPrimaryClaim(left, { ...left, publicKey: Uint8Array.from(publicKey) }), true); + const flipped = Uint8Array.from(publicKey); + flipped[31] ^= 1; + const differing: Record = { + algorithm: { ...left, algorithm: "ecdsa-secp256k1" as never }, + canonicalClaim: { ...left, canonicalClaim: `did:demos:agent:${"0".repeat(64)}` }, + "key length": { ...left, publicKey: Uint8Array.from([...publicKey, 0]) }, + "key bytes": { ...left, publicKey: flipped }, + }; + for (const [name, right] of Object.entries(differing)) { + assert.equal(sameResolvedPrimaryClaim(left, right), false, name); + assert.equal(sameResolvedPrimaryClaim(right, left), false, `${name} (reversed)`); + } +}); diff --git a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts index 49b6bce..16d0b70 100644 --- a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts +++ b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts @@ -44,11 +44,11 @@ const scope = { pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, - { kind: "pay-dem", parameters: { rail: "pay-dem" } }, + { kind: "pay-dem", parameters: { rail: "demos-native:DEM" } }, { kind: "deliver-storage-program" }, ], pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, - acceptedRails: [{ railId: "pay-dem" }], + acceptedRails: [{ railId: "demos-native:DEM" }], terms: {}, validity: { notBefore: 1 }, }; @@ -103,3 +103,16 @@ test("the SDK ordered reader with the acceptance verifiers accepts SIG-6 and rej const rejected = await validateListingArtifact(hexListing, readerDeps()); assert.equal(rejected.disposition, "rejected"); }); + +test("the identity presentation verifier accepts only per-claim presentations", async () => { + const verifyIdentityPresentation = sdkIdentityPresentationVerifier(); + type Bundle = Parameters[0]["bundle"]; + const perClaim = scope.seller.identity as unknown as Bundle; + const signedBytes = Buffer.from(`dacs-bundle-presentation:v1:${contentHash(identityScope)}`, "utf8"); + assert.equal(await verifyIdentityPresentation({ bundle: perClaim, signedBytes }), true); + // The same valid per-claim signature carried under every other presentation kind. + for (const kind of ["siwd", "session-key", "sr1-root"]) { + const bundle = { ...perClaim, presentation: { ...perClaim.presentation, kind } } as unknown as Bundle; + assert.equal(await verifyIdentityPresentation({ bundle, signedBytes }), false, kind); + } +}); From d0da4716b0e0cc77da86ff8147caaa8f73deb3ff Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:14:25 +0000 Subject: [PATCH 19/37] Verify the pinned SDK and Standard revisions during setup setup-sdk.sh accepts a packaged SDK build only when it carries the pinned commit marker the source build writes, checks the source checkout is a clean HEAD at SDK_REV, and on both paths requires the SDK to declare the pinned Standard revision. Public Standard links name that revision instead of main, and the README scopes the rail and finality claims. Co-Authored-By: Claude Opus 5.5 --- docs/codex-local-workflow.md | 2 + .../dacs-directory/README.md | 7 ++- .../dacs-directory/app/how-it-works/page.tsx | 4 +- .../dacs-directory/app/layout.tsx | 4 +- .../dacs-directory/scripts/setup-sdk.sh | 31 ++++++++++++- .../test/discovery-contract.test.ts | 5 +- .../test/local-workflow.test.mjs | 2 + .../dacs-directory/test/setup-sdk.test.mjs | 46 +++++++++++++++++++ 8 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/setup-sdk.test.mjs diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 8b2cc41..6143128 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -92,6 +92,8 @@ stops at that check: [ "$(git -C "$sdk" rev-parse HEAD)" = "$sdk_rev" ] || die "$sdk is not at $sdk_rev" (cd "$sdk" && npm ci --no-audit --no-fund && npm run build) + # scripts/setup-sdk.sh accepts a packaged dist only with this marker. + printf '%s' "$sdk_rev" > "$sdk/dist/.dacs-sdk-revision" npm ci --no-audit --no-fund ) ``` diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index a24e7a4..296bcf7 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -24,7 +24,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` 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 the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | +| Seller publication | DACS-1 §6.3.4 | `/register` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`; the pinned Standard publishes no signed v0.1 snapshot, so this is not a canonically anchored result), 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 finalized `AnchorReceipt` in the pinned SDK's portable Demos form (the write is read back by hash, and finality is the BFT confirmation the configured Demos node reports; the Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum), and independently verifies native address, owner, program name, tuple, identity, signature and content hash before the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | | 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 | @@ -109,7 +109,10 @@ railway up . --no-gitignore ``` `.railwayignore` includes only the SDK's compiled `dist` output from the otherwise ignored -vendor directory. +vendor directory. `scripts/setup-sdk.sh` accepts that packaged build only when +`dist/.dacs-sdk-revision` names the pinned `SDK_REV` (written by the source build after it +checks a clean checkout of that commit) and the SDK's `package.json` declares the pinned +`dacs.standardRevision`; otherwise it refuses to continue. ### Configuration 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 1b56ed7..ad0fcac 100644 --- a/reference-implementations/dacs-directory/app/how-it-works/page.tsx +++ b/reference-implementations/dacs-directory/app/how-it-works/page.tsx @@ -96,9 +96,9 @@ export default function HowItWorks() {

    Publish a current DACS listing on-chain, optionally link external identities, and register your pointers — the catalog verifies the artifact and labels the available evidence. Start with the{" "} - builder guide{" "} + builder guide{" "} or read the{" "} - standard. + standard.

    Register an agent diff --git a/reference-implementations/dacs-directory/app/layout.tsx b/reference-implementations/dacs-directory/app/layout.tsx index a5b45ff..7c85e5e 100644 --- a/reference-implementations/dacs-directory/app/layout.tsx +++ b/reference-implementations/dacs-directory/app/layout.tsx @@ -55,9 +55,9 @@ export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index 5ba51cd..38db2b0 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -23,15 +23,44 @@ git_with_sdk_auth() { command git "$@" } +# The DACS Standard revision the pinned SDK declares (package.json +# dacs.standardRevision); the Directory conforms to the same revision. +STANDARD_REV="d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20" +SDK_REV_MARKER="vendor/dacs-sdk/dist/.dacs-sdk-revision" + +require_standard_revision() { + local declared + declared="$(node -e 'process.stdout.write(String(require(process.argv[1]).dacs?.standardRevision ?? ""))' "$PWD/vendor/dacs-sdk/package.json")" + if [ "$declared" != "$STANDARD_REV" ]; then + echo "dacs-sdk declares Standard revision '${declared}', expected ${STANDARD_REV}" >&2 + exit 1 + fi +} + if [ -d vendor/dacs-sdk/dist ] && [ ! -d vendor/dacs-sdk/.git ]; then - echo "using packaged dacs-sdk build" + # A packaged build is accepted only when the source build below produced it + # from the pinned commit. + if [ "$(cat "$SDK_REV_MARKER" 2>/dev/null)" != "$SDK_REV" ]; then + echo "packaged dacs-sdk build is not marked as built from ${SDK_REV}; refusing it" >&2 + exit 1 + fi + require_standard_revision + echo "using packaged dacs-sdk build of ${SDK_REV}" else if [ ! -d vendor/dacs-sdk ]; then mkdir -p vendor 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") + if [ "$(git -C vendor/dacs-sdk rev-parse HEAD)" != "$SDK_REV" ] || + [ -n "$(git -C vendor/dacs-sdk status --porcelain --untracked-files=no)" ]; then + echo "vendor/dacs-sdk is not a clean checkout of ${SDK_REV}" >&2 + exit 1 + fi + require_standard_revision + rm -f "$SDK_REV_MARKER" (cd vendor/dacs-sdk && npm ci --no-audit --no-fund && npm run build) + printf '%s' "$SDK_REV" > "$SDK_REV_MARKER" fi if [ "${DACS_SKIP_APP_INSTALL:-0}" != "1" ]; then npm ci --no-audit --no-fund diff --git a/reference-implementations/dacs-directory/test/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index 6edbad9..cd088b0 100644 --- a/reference-implementations/dacs-directory/test/discovery-contract.test.ts +++ b/reference-implementations/dacs-directory/test/discovery-contract.test.ts @@ -27,7 +27,10 @@ test("public navigation never sends visitors to the private SDK repository", () for (const file of files) { const source = readFileSync(file, "utf8"); assert.doesNotMatch(source, /github\.com\/DACS-Agent-commerce\/dacs-sdk/); - assert.match(source, /DACS-Standard\/blob\/main\/docs\/builders-guide\.md/); + // Normative links name the Standard revision the pinned SDK declares, never mutable main. + assert.match(source, /DACS-Standard\/blob\/d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20\/docs\/builders-guide\.md/); + assert.match(source, /DACS-Standard\/tree\/d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20"/); + assert.doesNotMatch(source, /DACS-Standard\/(?:blob|tree)\/main/); } }); diff --git a/reference-implementations/dacs-directory/test/local-workflow.test.mjs b/reference-implementations/dacs-directory/test/local-workflow.test.mjs index ed78381..41f1612 100644 --- a/reference-implementations/dacs-directory/test/local-workflow.test.mjs +++ b/reference-implementations/dacs-directory/test/local-workflow.test.mjs @@ -61,6 +61,8 @@ if (args[0] === 'test' || (args[1] === 'build' && process.env.WORKFLOW_CHECKS)) 'await import("./src/catalog/indexer.ts")'], {cwd:process.env.WORKFLOW_APP, env:process.env, stdio:'inherit'}); if (run.status !== 0) process.exit(run.status ?? 1); } +// A real build emits dist/, where the documented install records the SDK revision. +if (args[0] === 'run' && args[1] === 'build') require('node:fs').mkdirSync('dist', { recursive: true }); if (args[0] === 'test' && process.env.WORKFLOW_FAIL_TEST) process.exit(23); `, { mode: 0o755 }); const run = (block) => spawnSync("bash", ["-c", block], { cwd, env, encoding: "utf8", timeout: 30_000 }); diff --git a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs new file mode 100644 index 0000000..e67a009 --- /dev/null +++ b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const script = new URL("../scripts/setup-sdk.sh", import.meta.url); +const pinned = readFileSync(script, "utf8"); +const sdkRev = pinned.match(/^SDK_REV="([0-9a-f]{40})"$/m)?.[1]; +const standardRev = pinned.match(/^STANDARD_REV="([0-9a-f]{40})"$/m)?.[1]; + +/** Run setup against a packaged (no .git) vendor/dacs-sdk tree; never installs anything. */ +function setupWithPackagedSdk({ marker, standardRevision }) { + const root = mkdtempSync(join(tmpdir(), "dacs-setup-sdk-")); + try { + mkdirSync(join(root, "scripts")); + copyFileSync(script, join(root, "scripts/setup-sdk.sh")); + mkdirSync(join(root, "vendor/dacs-sdk/dist"), { recursive: true }); + writeFileSync(join(root, "vendor/dacs-sdk/dist/index.js"), "export {};\n"); + writeFileSync(join(root, "vendor/dacs-sdk/package.json"), JSON.stringify({ dacs: { standardRevision } })); + if (marker !== undefined) writeFileSync(join(root, "vendor/dacs-sdk/dist/.dacs-sdk-revision"), marker); + return spawnSync("bash", [join(root, "scripts/setup-sdk.sh")], { + env: { ...process.env, DACS_SKIP_APP_INSTALL: "1" }, + encoding: "utf8", + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test("setup accepts a packaged SDK build only when it is marked as the pinned commit and Standard revision", () => { + assert.match(sdkRev ?? "", /^[0-9a-f]{40}$/); + assert.equal(standardRev, "d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20"); + const accepted = setupWithPackagedSdk({ marker: sdkRev, standardRevision: standardRev }); + assert.equal(accepted.status, 0, accepted.stderr); + for (const [name, tree] of Object.entries({ + "unmarked prebuilt dist": { standardRevision: standardRev }, + "dist built from another commit": { marker: "0".repeat(40), standardRevision: standardRev }, + "SDK for another Standard revision": { marker: sdkRev, standardRevision: "4bb9e48a1095ab32c06c25b7c0b52018d3ce4091" }, + })) { + const refused = setupWithPackagedSdk(tree); + assert.notEqual(refused.status, 0, name); + assert.doesNotMatch(refused.stdout, /setup complete/, name); + } +}); From e0c33b47e5f4d44c9a58d372140bd6e546150e84 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:23 +0000 Subject: [PATCH 20/37] Accept parameter-bearing revocation bindings in the well-known crawler Since bindings carry the Listing's exact presentedBy bytes, the crawler rejected the Directory's own listings.json for any revoked listing whose presentedBy has CF-3 parameters. It now accepts a binding whose claim is the index seller canonically and whose logical address derives from the binding's own claim. crawlDomain takes an injectable request function so the generator-to-crawler round trip and the index integrity checks (index hash, version, generatedAt, seller, bundle-binding hash) run offline, and the indexer's declared content-hash check is pinned. scan-fairness adds the thrown-fetch case (timeout, refused connection) to the known-marker and owner-candidate revocation reads. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/wellknown.ts | 34 ++-- .../dacs-directory/test/scan-fairness.test.ts | 16 +- .../test/wellknown-crawl.test.ts | 184 ++++++++++++++++++ 3 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/wellknown-crawl.test.ts diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 8960ca7..7f2735b 100644 --- a/reference-implementations/dacs-directory/src/catalog/wellknown.ts +++ b/reference-implementations/dacs-directory/src/catalog/wellknown.ts @@ -87,15 +87,16 @@ export function projectActiveListingIndexEntries( } } else { const revocation = entry.revocation; - const expectedLogicalAddress = - `dacs1-revoked:${encodeAddressSegment(seller)}:${entry.listingId}:v${entry.version}`; + // RB-2: the binding carries the Listing's exact `presentedBy` bytes, + // which may hold CF-3 advisory parameters; identity is the canonical claim. if ( !isRevocationBinding(revocation) || - revocation.sellerPrimaryClaim !== seller || + canonicalDemosAgentClaim(revocation.sellerPrimaryClaim) !== seller || + revocation.logicalAddress !== + `dacs1-revoked:${encodeAddressSegment(revocation.sellerPrimaryClaim)}:${entry.listingId}:v${entry.version}` || revocation.listingId !== entry.listingId || revocation.listingVersion !== entry.version || - revocation.listingContentHash !== entry.contentHash || - revocation.logicalAddress !== expectedLogicalAddress + revocation.listingContentHash !== entry.contentHash ) { return { ok: false, error: "a revoked listing entry has no matching revocation binding" }; } @@ -118,9 +119,15 @@ export function normalizeSubmittedDomain(domain: string): string { return url.origin; } -async function fetchJson(url: string): Promise<{ body: unknown; raw: string } | null> { +/** The bounded public-HTTPS GET the crawler uses; tests inject a stub. */ +export type WellKnownRequest = ( + url: string, + options: { method: "GET"; accept: string }, +) => Promise<{ status: number; body: Buffer }>; + +async function fetchJson(url: string, request: WellKnownRequest): Promise<{ body: unknown; raw: string } | null> { try { - const response = await boundedPublicHttpsRequest(url, { method: "GET", accept: "application/json" }); + const response = await request(url, { method: "GET", accept: "application/json" }); if (response.status < 200 || response.status >= 300) return null; const raw = response.body.toString("utf8"); return { body: JSON.parse(raw), raw }; @@ -129,7 +136,10 @@ async function fetchJson(url: string): Promise<{ body: unknown; raw: string } | } } -export async function crawlDomain(domain: string): Promise { +export async function crawlDomain( + domain: string, + request: WellKnownRequest = boundedPublicHttpsRequest, +): Promise { let base: string; try { base = normalizeSubmittedDomain(domain); @@ -137,13 +147,13 @@ export async function crawlDomain(domain: string): Promise" over the index bytes. @@ -168,7 +178,7 @@ export async function crawlDomain(domain: string): Promise = []; for (let i = 0; i < domains.length; i += 5) { - const batch = await Promise.all(domains.slice(i, i + 5).map(crawlDomain)); + const batch = await Promise.all(domains.slice(i, i + 5).map((domain) => crawlDomain(domain))); for (const r of batch) { if ("error" in r) errors.push(r); else agents.push(r); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 00e401b..3adc907 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -704,20 +704,30 @@ test("an unavailable or not-found candidate anchored by the listing owner withho for (const [writer, after, expected] of [ [outsider, "unavailable", "active"], [outsider, "gone", "active"], + [outsider, "rejects", "active"], [seller, "gone", undefined], [seller, "unavailable", undefined], + [seller, "rejects", undefined], ] as const) { resetIndex([sellerRegistration, later], 32); const programs: Record = { [locator(1)]: { data: JSON.stringify(listing) } }; const candidate = { data: JSON.stringify(marker), owner: ownerOf(writer) }; let served = false; - // The scan reads the candidate once; afterwards the node answers 503 or 404. + // The scan reads the candidate once; afterwards the node answers 503 or 404, or fetch rejects. Object.defineProperty(programs, locator(61), { enumerable: true, - get: () => after === "unavailable" || !served ? (served = true, candidate) : undefined, + get: () => after !== "gone" || !served ? (served = true, candidate) : undefined, }); chain(programs, [locator(1), locator(61)]); if (after === "unavailable") scanOnly.add(locator(61)); + if (after === "rejects") { + let answered = false; + answers.set(locator(61), () => { + if (answered) throw new TypeError("fetch failed"); + answered = true; + return Response.json({ success: true, owner: candidate.owner, programName: "opaque", data: marker }); + }); + } await reindex(); assert.deepEqual(pending(listingHash), [locator(61)], "the candidate was discovered but not read"); assert.equal(listingStatus(), expected, `${writer} ${after}`); @@ -769,6 +779,8 @@ test("a known verified marker that answers without a readable artifact withholds ["malformed JSON", (at) => answers.set(at, () => new Response('{"success":true,"data":', { status: 200 }))], ["success false", (at) => answers.set(at, () => Response.json({ success: false }))], ["non-object data", (at) => answers.set(at, () => Response.json({ success: true, data: [1] }))], + // The node never answers: fetch rejects, as on a timeout, refused connection or DNS failure. + ["fetch rejects", (at) => answers.set(at, () => { throw new TypeError("fetch failed"); })], ]; const summary = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]; for (const [name, outage] of cases) { diff --git a/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts b/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts new file mode 100644 index 0000000..0b0f9fb --- /dev/null +++ b/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; +import { contentHash, sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; + +// §6.3.5 end to end: the Directory's own well-known generator, its crawler and +// the declared-hash check in the indexer, over a stubbed node and web server. +process.env.DACS_DIRECTORY_DATA = mkdtempSync(join(tmpdir(), "dacs-directory-wellknown-")); +process.env.DEMOS_RPC = "https://node.test"; +const store = await import("../src/catalog/store.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { findValidListingRevocation, verifyListing } = await import("../src/catalog/listingVerification.js"); +const { crawlDomain, projectActiveListingIndexEntries } = await import("../src/catalog/wellknown.js"); +const wellknownFiles = await import("../app/api/dacs/wellknown-files/route.js"); + +const seed = Uint8Array.from(Buffer.alloc(32, 0x31)); +const privateKey = privateKeyFromSeed(seed); +const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; +const owner = `0x${claim.slice(-64)}`; +// CF-3 advisory parameters: not part of identity, but RB-2 binds their exact bytes. +const presentedBy = `${claim}?channel=directory`; +const sign = (message: string) => Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); +const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; + +function listingFor(listingId: string): Record { + const identity: Record = { + bundleVersion: "1", presentedBy, presentedAt: 1, claims: [{ ref: presentedBy, kind: "signing-key" }], + }; + identity.presentation = { kind: "per-claim", signatures: [{ ref: presentedBy, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`) }] }; + const scope: Record = { + dacsVersion: "1", listingVersion: 1, listingId, + seller: { displayName: "Well-known seller", identity }, + offering: { title: listingId, description: "served from a domain", category: "conformance.signature", tags: [], deliverable: { kind: "storage-program" } }, + buyerRequirement: { requirementVersion: "1", required: [] }, + pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "demos-native:DEM" } }, { kind: "deliver-storage-program" }], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "demos-native:DEM" }], + terms: {}, validity: { notBefore: 1 }, + }; + return { ...scope, signature: { algorithm: "ed25519", signer: presentedBy, value: sign(`dacs-listing:v1:${contentHash(scope)}`) } }; +} + +const active = listingFor("wk-active"); +const revoked = listingFor("wk-revoked"); +const storage: Record> = { [locator(1)]: active, [locator(2)]: revoked }; +globalThis.fetch = async (input) => { + const url = new URL(String(input)); + const at = url.pathname.startsWith("/storage-program/") ? url.pathname.slice("/storage-program/".length) : null; + if (!at) return new Response(null, { status: 503 }); // identity lookups are best effort + if (!storage[at]) return new Response(null, { status: 404 }); + return Response.json({ success: true, owner, programName: "opaque", data: storage[at] }); +}; +const offline = async (): Promise => { throw new Error("identity unavailable"); }; +const registration = { primaryClaim: claim, displayName: "Well-known seller", listingAnchors: [locator(1), locator(2)] }; + +const verifiedRevoked = (await verifyListing(revoked))!; +const markerScope = { listingId: "wk-revoked", listingVersion: 1, listingContentHash: verifiedRevoked.contentHash, revokedAt: 1 }; +const marker = { ...markerScope, signature: { algorithm: "ed25519", signer: presentedBy, value: sign(`dacs-revocation:v1:${contentHash(markerScope)}`) } }; +const binding = (await findValidListingRevocation([locator(60)], verifiedRevoked, 1, async () => marker))!; + +/** Publish the catalog, then generate the seller's hosted files exactly as the Directory does. */ +async function generatedFiles(): Promise> { + const record = await indexRegistration(registration, undefined, offline); + record.listings = record.listings.map((listing) => listing.listingId === "wk-revoked" + ? { ...listing, status: "revoked", revocation: binding } : listing); + store.saveCatalog({ catalogVersion: "1", generatedAt: 1, sellers: [record] }); + const response = await wellknownFiles.GET(new NextRequest( + `https://directory.test/api/dacs/wellknown-files?claim=${encodeURIComponent(claim)}&domain=1.1.1.1`, + )); + assert.equal(response.status, 200); + return (await response.json()).files; +} + +// A public IP literal needs no DNS lookup; the stub serves every request. +const domain = "1.1.1.1"; +const served = (files: Record) => async (url: string) => { + const path = new URL(url).pathname; + return path in files ? { status: 200, body: Buffer.from(files[path]!) } : { status: 404, body: Buffer.alloc(0) }; +}; +const rehashed = (files: Record, listings: string) => { + const card = JSON.parse(files["/.well-known/agent.json"]!); + card.dacs.listings.indexHash = `sha256-${sha256Hex(listings)}`; + return { "/.well-known/agent.json": JSON.stringify(card), "/.well-known/dacs/listings.json": listings }; +}; + +test("the crawler accepts the well-known files the Directory generates for a parameter-bearing presentedBy", async () => { + const files = await generatedFiles(); + const index = JSON.parse(files["/.well-known/dacs/listings.json"]!); + assert.equal(index.listings.find((entry: { listingId: string }) => entry.listingId === "wk-revoked").revocation.sellerPrimaryClaim, presentedBy); + const crawled = await crawlDomain(domain, served(files)); + assert.ok(!("error" in crawled), JSON.stringify(crawled)); + assert.equal(crawled.seller, claim); + assert.deepEqual(crawled.listingAnchors, [locator(1)], "only the active listing is indexed"); + assert.deepEqual(crawled.contentHashes, { [locator(1)]: (await verifyListing(active))!.contentHash }); +}); + +test("a revocation binding is accepted only for the index seller and the address its own claim derives", () => { + const entry = { + listingId: "wk-revoked", version: 1, contentHash: verifiedRevoked.contentHash, + anchor: { kind: "storage-program", locator: locator(2) }, status: "revoked", revocation: binding, + }; + assert.deepEqual(projectActiveListingIndexEntries([entry], claim), { ok: true, listingAnchors: [], contentHashes: {} }); + const other = `did:demos:agent:${"b".repeat(64)}`; + const canonicalAddress = `dacs1-revoked:${claim.replaceAll(":", "%3A")}:wk-revoked:v1`; + for (const [name, revocation, seller] of [ + ["another seller's index", binding, other], + ["another agent's claim", { ...binding, sellerPrimaryClaim: `${other}?channel=directory` }, claim], + ["address of the canonical claim, not the presented one", { ...binding, logicalAddress: canonicalAddress }, claim], + ["another version's address", { ...binding, logicalAddress: binding.logicalAddress.replace(/:v1$/, ":v2") }, claim], + ] as const) { + assert.equal(projectActiveListingIndexEntries([{ ...entry, revocation }], seller).ok, false, name); + } +}); + +test("the crawler refuses well-known files whose binding, version or seller does not hold", async () => { + const files = await generatedFiles(); + const listings = files["/.well-known/dacs/listings.json"]!; + const index = JSON.parse(listings); + const card = JSON.parse(files["/.well-known/agent.json"]!); + const withCard = (change: (value: typeof card) => void) => { + const copy = structuredClone(card); + change(copy); + return { ...files, "/.well-known/agent.json": JSON.stringify(copy) }; + }; + const cases: Array<[string, Record, RegExp]> = [ + ["no agent card", { "/.well-known/dacs/listings.json": listings }, /no \.well-known\/agent\.json/], + ["unsupported dacsVersion", withCard((value) => { value.dacs.dacsVersion = "2"; }), /no supported dacs\.listings/], + ["no index URL", withCard((value) => { delete value.dacs.listings.indexUrl; }), /no supported dacs\.listings/], + ["index unreachable", { "/.well-known/agent.json": files["/.well-known/agent.json"]! }, /listings index unreachable/], + ["index bytes changed", { ...files, "/.well-known/dacs/listings.json": `${listings}\n` }, /indexHash mismatch/], + ["no declared index hash", withCard((value) => { delete value.dacs.listings.indexHash; }), /indexHash mismatch/], + ["another index's hash", withCard((value) => { value.dacs.listings.indexHash = `sha256-${"0".repeat(64)}`; }), /indexHash mismatch/], + ["index version 2", rehashed(files, JSON.stringify({ ...index, indexVersion: "2" })), /invalid version or generatedAt/], + ["generatedAt 0", rehashed(files, JSON.stringify({ ...index, generatedAt: 0 })), /invalid version or generatedAt/], + ["fractional generatedAt", rehashed(files, JSON.stringify({ ...index, generatedAt: 1.5 })), /invalid version or generatedAt/], + ["non-canonical seller", rehashed(files, JSON.stringify({ ...index, seller: claim.toUpperCase() })), /no canonical Demos seller/], + ["another seller", rehashed(files, JSON.stringify({ ...index, seller: `did:demos:agent:${"b".repeat(64)}` })), /no matching revocation binding/], + ]; + for (const [name, hosted, error] of cases) { + const crawled = await crawlDomain(domain, served(hosted)); + assert.ok("error" in crawled, name); + assert.match(crawled.error, error, name); + } + // The declared hash is case-insensitive hex with an optional `sha256-` prefix. + const upper = withCard((value) => { value.dacs.listings.indexHash = sha256Hex(listings).toUpperCase(); }); + assert.ok(!("error" in await crawlDomain(domain, served(upper)))); + // A server that never answers is an unreachable card, not a crash. + const down = await crawlDomain(domain, async () => { throw new Error("connection refused"); }); + assert.deepEqual(down, { domain, error: "no .well-known/agent.json" }); +}); + +test("a bundle-binding index is bound by its own declared hash", async () => { + const files = await generatedFiles(); + const bindings = JSON.stringify({ bindings: [] }); + const card = JSON.parse(files["/.well-known/agent.json"]!); + const hosted = (indexHash: string) => { + card.dacs.bundleBindings = { indexUrl: `https://${domain}/.well-known/dacs/bindings.json`, indexHash }; + return { ...files, "/.well-known/agent.json": JSON.stringify(card), "/.well-known/dacs/bindings.json": bindings }; + }; + const accepted = await crawlDomain(domain, served(hosted(`sha256-${sha256Hex(bindings)}`))); + assert.ok(!("error" in accepted), JSON.stringify(accepted)); + assert.deepEqual(accepted.bundleBindings, []); + for (const indexHash of ["", `sha256-${"0".repeat(64)}`]) { + const refused = await crawlDomain(domain, served(hosted(indexHash))); + assert.ok("error" in refused); + assert.match(refused.error, /bundle-binding indexHash mismatch/); + } +}); + +test("a listing is indexed only when it matches the content hash its domain declares", async () => { + const activeHash = (await verifyListing(active))!.contentHash; + const indexed = async (declared: string) => (await indexRegistration( + { ...registration, listingAnchors: [locator(1)], listingContentHashes: { [locator(1)]: declared } }, + undefined, + offline, + )).listings.map((listing) => listing.listingId); + assert.deepEqual(await indexed(`sha256-${activeHash.toUpperCase()}`), ["wk-active"]); + assert.deepEqual(await indexed(activeHash), ["wk-active"]); + assert.deepEqual(await indexed("0".repeat(64)), [], "a declared hash that does not match withholds the listing"); +}); From ad5fd36bb442d40b161bba29972ba4dee8a9532a Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:23 +0000 Subject: [PATCH 21/37] Pin the indexer's deal-evidence guards and seller labels - current-profile deals: the published deal record, its ratings, settlement refs and category reputation hint; a BundleBinding that points at the copy anchored by the other role leaves that side indeterminate - seller labels: owner badge only for a verifiable stored signature (fresh or not), never for other content or a throwing verifier; a signing-key-only IdentityBundle stays self-declared - legacy two-copy reader: reached when the routing read fails and a later read succeeds. Every guard withholds reputation, a buyer copy reports the seller-relative outcome and its own anchor time, and a fully signed legacy deal never counts because the pinned SDK authenticates no legacy agreement (legacy-agreement-signatures-missing) Co-Authored-By: Claude Opus 5.5 --- .../test/current-indexer.test.ts | 87 +++++++++- .../test/legacy-deal-evidence.test.ts | 157 ++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index b0df804..486b860 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -15,7 +15,8 @@ import { deriveSellerReputation, isNeutralCancellation } from "../src/catalog/re import { verifyListing } from "../src/catalog/listingVerification.js"; import { boundedBundleBindings, logicalBundleAddress } from "../src/catalog/bundleBinding.js"; import { loadScanState, saveScanState } from "../src/catalog/store.js"; -import type { BundleBinding, DealRecord, RegisteredDeal } from "../src/catalog/types.js"; +import { registrationMessage } from "../src/catalog/registrationSig.js"; +import type { BundleBinding, DealRecord, RegisteredDeal, Registration } from "../src/catalog/types.js"; type Obj = Record; const seeds = [11, 12].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); @@ -1054,3 +1055,87 @@ test("identity tier requires a fresh passing version-pinned VerifyResult and its ); assert.equal(await deriveIdentityTier(identity, async () => policy, 201, async (at) => maps.get(at) ?? null), "self-declared"); }); + +/** Index the seller's registration over the fixture's stubbed storage. */ +async function indexFixture(fixture: Awaited>, extra: Partial = {}) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const data = maps.get(String(input).split("/").pop() ?? ""); + return new Response(JSON.stringify(data ? { success: true, owner: `0x${dids[1].slice(-64)}`, programName: "dacs:test", data } : { success: false }), + { status: data ? 200 : 404, headers: { "content-type": "application/json" } }); + }; + try { + return await indexRegistration({ primaryClaim: dids[1], displayName: "seller", listingAnchors: [fixture.locators.listing], ...extra }, + undefined, async () => { throw new Error("identity unavailable"); }); + } finally { + globalThis.fetch = originalFetch; + } +} + +test("a verified two-copy deal publishes exactly the evidence its authoritative copy signs", async () => { + const fixture = await vector("projection-job", 130); + const deal = registeredDeal("projection-job", fixture.locators.buyer, fixture.locators.seller); + const record = await indexFixture(fixture, { deals: [deal], bundleBindings: [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.seller, fixture.sellerBundle, 1), + ] }); + const [published] = record.deals; + assert.deepEqual({ + signatureVerified: published.signatureVerified, refsVerified: published.refsVerified, + reputationEligible: published.reputationEligible, rail: published.rail, outcome: published.outcome, + sellerOutcome: published.sellerOutcome, anchoredByRole: published.anchoredByRole, + bundleContentHash: published.bundleContentHash, cancellationNeutral: published.cancellationNeutral, + finalisedAt: published.finalisedAt, agreementPrice: published.agreementPrice, category: published.category, + ratings: published.ratings, settlementTxIds: published.settlementTxIds, + }, { + signatureVerified: true, refsVerified: true, reputationEligible: true, rail: "pay-dem", outcome: "completed", + sellerOutcome: "completed", anchoredByRole: "seller", bundleContentHash: artifactHash(fixture.sellerBundle, "bundle"), + cancellationNeutral: false, finalisedAt: 120, agreementPrice: { amount: "1.25", currency: "DEM" }, category: "services.test", + ratings: [{ rater: dids[0], target: dids[1], targetRole: "seller", value: 5, ratedAt: 110, contentHash: artifactHash(fixture.rating, "rating") }], + settlementTxIds: [{ id: "tx-projection-job", observedAt: 100, phaseIndex: 2 }], + }); + const { categoryScope, completionRate, averageSellerRating, bundleCount } = record.listings[0].reputationHint!; + assert.deepEqual({ categoryScope, completionRate, averageSellerRating, bundleCount }, + { categoryScope: "services.test", completionRate: 1, averageSellerRating: 5, bundleCount: 1 }); + assert.equal(record.reputation.bundleCount, 1); +}); + +test("a BundleBinding resolves only to the copy anchored by the role it names", async () => { + // Both copies share one bundle hash; only the unhashed anchoredByRole tells them apart (BB-5). + const fixture = await vector("misrole-job", 150); + const deal = registeredDeal("misrole-job", fixture.locators.buyer, fixture.locators.seller); + const record = await indexFixture(fixture, { deals: [deal], bundleBindings: [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.buyer, fixture.buyerBundle, 1), + ] }); + const [published] = record.deals; + assert.equal(published.signatureVerified, false, "the seller side is indeterminate, so nothing is published as verified"); + assert.equal(published.refsVerified, false); + assert.equal(published.outcome, undefined); + assert.equal(published.anchoredByRole, undefined); + assert.equal(record.reputation.bundleCount, 0); +}); + +test("seller labels come only from a verifiable owner signature and verified identity claims", async () => { + const fixture = await vector("labels-job", 170); + const base = { primaryClaim: dids[1], displayName: "seller", listingAnchors: [fixture.locators.listing] }; + const signed = async (signedAt: number, extra: Partial = {}) => { + const message = registrationMessage({ ...base, ...extra }, signedAt); + const value = Buffer.from(await ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[1]))).toString("hex"); + return { ...extra, ownerSignature: { message, signature: value, signedAt } }; + }; + const unsigned = await indexFixture(fixture); + assert.equal(unsigned.ownerRegistered, false); + // A v0.1 IdentityBundle with only its signing key is self-declared, never verified or institutional. + assert.equal(unsigned.identityTier, "self-declared"); + assert.ok(unsigned.identityBundle); + assert.equal((await indexFixture(fixture, await signed(Date.now()))).ownerRegistered, true); + // Freshness gates submission only; a stored signature still binds on every reindex. + assert.equal((await indexFixture(fixture, await signed(1))).ownerRegistered, true); + const { ownerSignature: other } = await signed(Date.now(), { displayName: "someone else" }); + assert.equal((await indexFixture(fixture, { ownerSignature: other })).ownerRegistered, false, "a signature over other content"); + // A verifier that throws on a malformed stored signature never grants the badge. + const malformed = await signed(Date.now()); + (malformed.ownerSignature as { signature: unknown }).signature = 42; + assert.equal((await indexFixture(fixture, malformed)).ownerRegistered, false); +}); diff --git a/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts new file mode 100644 index 0000000..e8563d7 --- /dev/null +++ b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import type { RegisteredDeal } from "../src/catalog/types.js"; + +// The legacy SDK-MVP two-copy reader (indexer `verifyCopy`). The routing read +// sends every `bundleVersion: "1"` copy to the current verifier, so this reader +// sees a legacy bundle only when that first read failed and a later read in the +// same pass succeeded. Each guard on that path must still fail closed. +process.env.DACS_DIRECTORY_DATA = mkdtempSync(join(tmpdir(), "dacs-directory-legacy-deals-")); +process.env.DEMOS_RPC = "https://node.test"; +const store = await import("../src/catalog/store.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { deriveAnchorAddress } = await import("../src/catalog/chain.js"); +const { legacySessionAnchorName } = await import("../src/catalog/legacySessionAnchorName.js"); + +type Obj = Record; +const seeds = [41, 42, 43].map((byte) => Uint8Array.from(Buffer.alloc(32, byte))); +const dids = seeds.map((seed) => `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`); +const [buyer, seller, outsider] = dids; +const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; +const signWith = (index: number, message: string, encoding: "hex" | "base64url") => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKeyFromSeed(seeds[index]))).toString(encoding); +const partySignature = (index: number, prefix: string, scope: Obj) => + ({ party: dids[index], algorithm: "ed25519", value: signWith(index, prefix + contentHash(scope), "base64url") }); + +const listingScope: Obj = { + agentId: seller, serviceId: "svc", name: "Service", description: "Legacy service", claimRequirements: [], + supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], +}; +const listing = { ...listingScope, signature: signWith(1, `dacs-listing:v1:${contentHash(listingScope)}`, "hex") }; + +let storage: Record = {}; +let reads = new Map(); +/** Locators whose first read fails, as on a transient node error. */ +let flaky = new Set(); +globalThis.fetch = async (input) => { + const path = new URL(String(input)).pathname; + if (!path.startsWith("/storage-program/")) return new Response(null, { status: 503 }); + const at = path.slice("/storage-program/".length); + const count = (reads.get(at) ?? 0) + 1; + reads.set(at, count); + if (!storage[at] || (flaky.has(at) && count === 1)) return new Response(null, { status: 503 }); + return Response.json({ success: true, owner: `0x${(at === locator(1) ? seller : buyer).slice(-64)}`, programName: "opaque", data: storage[at] }); +}; + +interface Copy { role: "buyer" | "seller"; signers?: number[]; change?: (body: Obj) => void } + +/** A complete legacy graph: Listing, two-party agreement and one copy per role. */ +function legacyDeal(jobId: string, copies: [Copy, Copy]): RegisteredDeal { + const agreementScope: Obj = { + jobId, pattern: "fixed-price", buyer, seller, listingRef: locator(1), + price: { amount: "1", asset: "DEM", decimals: 18, rail: "pay-dem" }, + delivery: { phase: "deliver-attested-payload", format: "application/json" }, expiresAt: "2099-01-01T00:00:00.000Z", + }; + const agreement = { ...agreementScope, signatures: [0, 1].map((index) => partySignature(index, "dacs-agreement:v1:", agreementScope)) }; + storage[deriveAnchorAddress(buyer, legacySessionAnchorName.agreement(jobId))] = agreement; + const [buyerAt, sellerAt] = [locator(10), locator(11)]; + for (const [index, copy] of copies.entries()) { + const body: Obj = { + bundleVersion: "1", jobId, outcome: "completed", + listingRef: { listingId: "svc", version: 1, contentHash: contentHash(listingScope) }, + agreementRef: { kind: "dacs-3-agreement", id: `agreement-${jobId}`, contentHash: contentHash(agreementScope) }, + parties: [ + { role: "buyer", primaryClaim: buyer, bundleHash: "b".repeat(64) }, + { role: "seller", primaryClaim: seller, bundleHash: "s".repeat(64) }, + ], + phaseSummary: [{ index: 0, kind: "commit-agreement", outcome: "ok" }], vetRecords: [], settlementEvidence: [], + recipeRegistryVersion: 1, railRegistryVersion: 1, finalisedAt: 1_700_000_000_000, + }; + copy.change?.(body); + storage[index === 0 ? buyerAt : sellerAt] = { + ...body, + signatures: (copy.signers ?? [0, 1]).map((signer) => partySignature(signer, "dacs-bundle:v1:", body)), + anchoredByRole: copy.role, + }; + } + storage[locator(1)] = listing; + flaky = new Set([buyerAt, sellerAt]); + return { jobId, rail: "pay-dem", buyerBundleRef: buyerAt, sellerBundleRef: sellerAt, owners: { buyer, seller } }; +} + +async function indexed(deal: RegisteredDeal, primaryClaim = seller) { + reads = new Map(); + const record = await indexRegistration({ primaryClaim, displayName: "Seller", listingAnchors: [], deals: [deal] }, + undefined, async () => { throw new Error("identity unavailable"); }); + storage = {}; + return record; +} + +test("a fully signed legacy two-copy deal never counts: the pinned SDK authenticates no legacy agreement", async () => { + // verifyBundleCore reports every legacy agreement reference as `signature-missing` + // (legacy-agreement-signatures-missing), so the strict reference policy cannot pass. + const record = await indexed(legacyDeal("legacy-ok", [{ role: "buyer" }, { role: "seller" }])); + const [deal] = record.deals; + assert.equal(deal.outcome, "completed", "the legacy reader read and shape-checked both copies"); + assert.equal(deal.signatureVerified, true, "both parties signed the copy"); + assert.equal(deal.anchoredByRole, "buyer", "no copy passed the strict policy, so the buyer copy is reported"); + assert.equal(deal.refsVerified, false); + assert.equal(deal.reputationEligible, false); + assert.equal(record.reputation.bundleCount, 0); +}); + +test("each legacy two-copy guard withholds reputation", async () => { + const cases: Array<[string, () => RegisteredDeal, string?]> = [ + ["seller copy anchored by the buyer", () => legacyDeal("legacy-misrole", [{ role: "buyer" }, { role: "buyer" }])], + ["copies diverge on a phase fact", () => legacyDeal("legacy-diverge", [{ role: "buyer" }, { + role: "seller", change: (body) => { body.phaseSummary = [{ index: 0, kind: "commit-agreement", outcome: "fail" }]; }, + }])], + ["seller copy is missing the seller's signature", () => legacyDeal("legacy-unsigned", [{ role: "buyer" }, { role: "seller", signers: [0] }])], + ["buyer copy names another buyer", () => legacyDeal("legacy-other-buyer", [{ + role: "buyer", change: (body) => { (body.parties as Obj[])[0].primaryClaim = outsider; }, + }, { role: "seller" }])], + ["copies belong to another job", () => legacyDeal("legacy-job", [{ + role: "buyer", change: (body) => { body.jobId = "another-job"; }, + }, { role: "seller", change: (body) => { body.jobId = "another-job"; } }])], + ["catalog seller is not the bundle seller", () => legacyDeal("legacy-catalog", [{ role: "buyer" }, { role: "seller" }]), outsider], + ]; + for (const [name, build, primaryClaim] of cases) { + const record = await indexed(build(), primaryClaim); + const [deal] = record.deals; + assert.equal(deal.refsVerified, false, name); + assert.equal(deal.reputationEligible, false, name); + assert.equal(record.reputation.bundleCount, 0, name); + } +}); + +test("a legacy buyer-anchored copy reports the seller-relative outcome and its own SR-2 anchor time", async () => { + const deal = legacyDeal("legacy-abort", [ + { role: "buyer", change: (body) => { body.outcome = "aborted-by-self"; } }, + { role: "seller", change: (body) => { body.outcome = "aborted-by-other"; } }, + ]); + for (const [at, anchorTime] of [[deal.buyerBundleRef, 111], [deal.sellerBundleRef!, 222]] as const) { + store.recordArtifact({ locator: at, kind: "bundle", profile: "legacy", observedAt: 1, contentHash: "a".repeat(64), anchorTime }); + } + const [published] = (await indexed(deal)).deals; + assert.equal(published.outcome, "aborted-by-self"); + assert.equal(published.sellerOutcome, "aborted-by-other", "the buyer's own abort is the seller's counterparty abort"); + assert.equal(published.anchorTimestamp, 111, "the reported copy is the buyer's"); + assert.equal(published.reputationEligible, false); +}); + +test("a legacy deal with only one verifiable copy keeps its signature verdict but never counts", async () => { + const deal = legacyDeal("legacy-one-copy", [{ role: "buyer" }, { role: "seller" }]); + const [, sellerAt] = [deal.buyerBundleRef, deal.sellerBundleRef!]; + delete storage[sellerAt]; + const record = await indexed(deal); + assert.equal(record.deals[0].outcome, "completed"); + assert.equal(record.deals[0].signatureVerified, true, "the buyer copy is signed by both parties"); + assert.equal(record.deals[0].refsVerified, false, "strict two-copy verification needs the seller copy"); + assert.equal(record.deals[0].reputationEligible, false); + assert.equal(record.reputation.bundleCount, 0); +}); From bec26b678e126f704cb87b518131c15c6604aa59 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:23 +0000 Subject: [PATCH 22/37] Stop asserting a signature on the unsigned PA-1 rail table LRR-6 lets a PA-1 reader resolve only against a signed in-code snapshot, and the pinned Standard publishes the v0.1 registry unsigned. The publisher's in-code definitions now say signatureValid: false, so the SDK resolver returns indeterminate (pa1-definition-unverifiable) once every other LRR check passes. Publication accepts exactly that result or a real verified one, surfaces it as railResolution, and refuses any rejected or other indeterminate result; LP-6 verified is no longer claimed. Co-Authored-By: Claude Opus 5.5 --- .../app/api/dacs/build-listing/route.ts | 3 +- .../src/catalog/listingOptions.ts | 22 ++++++++--- .../dacs-directory/test/build-listing.test.ts | 7 +++- .../dacs-directory/test/rail-registry.test.ts | 37 ++++++++++++++++--- .../test/sdk-reader-verifiers.test.ts | 8 +++- 5 files changed, 61 insertions(+), 16 deletions(-) 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 fd15961..6563ddb 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 @@ -41,6 +41,7 @@ import { publishableRail, PUBLISHABLE_PRICING_KINDS, PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + railAuthorityPermitsPublication, RAIL_ID_PATTERN, type PublishablePricingKind, } from "@/src/catalog/listingOptions"; @@ -276,7 +277,7 @@ export async function POST(req: NextRequest) { registry: { state: "not-used", entries: [], definitions: [] }, inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, }); - if (railResolution.disposition !== "verified") { + if (!railAuthorityPermitsPublication(railResolution)) { return NextResponse.json( { error: `listing rail authority is ${railResolution.disposition} (${railResolution.reason}); publication refused` }, { status: 409 }, diff --git a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts index 2876f12..6997b52 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts @@ -20,19 +20,31 @@ export const PUBLISHABLE_RAIL_OPTIONS = [ /** * DACS-4 PA-1 definitions carried in this exact reviewed app release. * - * The pinned Standard publishes the v0.1 registry as a table, not as - * dacs-rail:v1-signed definitions, so `signatureValid` here records that the - * release itself is the in-code authority (LRR-6 `pa1-in-code`). It is not a - * signature check and never a canonically anchored result. + * LRR-6 lets a PA-1 reader resolve only against a disclosed, signed in-code + * snapshot. The pinned Standard publishes the v0.1 registry as an unsigned + * table and no steward key exists, so no definition signature is verified: + * `signatureValid` is false, and the SDK resolver answers `indeterminate` + * (`pa1-definition-unverifiable`) once every other LRR check passes. */ 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, + signatureValid: false, })); +/** + * Publication needs LP-6 `verified`, or the one `indeterminate` result in + * which every LRR check passed except the unsigned PA-1 table. The latter is + * surfaced as such and never described as `verified`; any `rejected` result, + * or another indeterminate reason, refuses publication. + */ +export function railAuthorityPermitsPublication(result: { disposition: string; reason?: string }): boolean { + return result.disposition === "verified" || + (result.disposition === "indeterminate" && result.reason === "pa1-definition-unverifiable"); +} + 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/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 836f536..3be9b39 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -95,13 +95,16 @@ test("publisher uses PA-1 rail resolution and signs the exact staged Listing bef programName: string; exists: boolean; publicationReady: boolean; - railResolution: { disposition: string; authorityBasis: string }; + railResolution: { disposition: string; reason: string; authorityBasis: string }; payloadCapability: { disposition: string }; tx: null; }; 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"); + // LRR-6 accepts only a signed PA-1 snapshot; this release's table is unsigned, so + // the publisher surfaces that result and never claims LP-6 `verified`. + assert.equal(preview.railResolution.disposition, "indeterminate"); + assert.equal(preview.railResolution.reason, "pa1-definition-unverifiable"); assert.equal(preview.railResolution.authorityBasis, "pa1-in-code"); assert.equal(preview.payloadCapability.disposition, "not-applicable"); assert.deepEqual(preview.listing.pricing, { diff --git a/reference-implementations/dacs-directory/test/rail-registry.test.ts b/reference-implementations/dacs-directory/test/rail-registry.test.ts index ec8f41b..aa4d741 100644 --- a/reference-implementations/dacs-directory/test/rail-registry.test.ts +++ b/reference-implementations/dacs-directory/test/rail-registry.test.ts @@ -7,7 +7,12 @@ import { NextRequest } from "next/server"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; import { listingPresentation } from "../src/catalog/listingMetadata.js"; -import { PUBLISHABLE_RAIL_OPTIONS, RAIL_ID_PATTERN } from "../src/catalog/listingOptions.js"; +import { + PUBLISHABLE_RAIL_OPTIONS, + PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + RAIL_ID_PATTERN, + railAuthorityPermitsPublication, +} from "../src/catalog/listingOptions.js"; import { resolveListingRails } from "../src/sdkListingValidation.js"; const dataDir = mkdtempSync(join(tmpdir(), "dacs-rail-registry-")); @@ -21,12 +26,13 @@ const registry = JSON.parse(readFileSync(new URL("./fixtures/dacs4-v0.1-rail-reg standardRevision: string; rails: Array<{ railId: string; phaseHandler: string }>; }; +// The pinned Standard publishes this table unsigned, so a PA-1 reader cannot verify it (LRR-6). const standardDefinitions = registry.rails.map((rail) => ({ railId: rail.railId, railVersion: 1, phaseHandler: rail.phaseHandler, governanceAnchoring: "in-code", - signatureValid: true, + signatureValid: false, })); const resolveAgainstStandard = (listing: { pipeline: Array<{ kind: string; parameters?: Record }>; acceptedRails: unknown[] }) => resolveListingRails({ @@ -53,7 +59,7 @@ test("every publishable rail is a DACS-4 §9.4.2 registry ID with its registered } }); -test("a /register-built Listing verifies under a reader using the Standard registry", async () => { +test("a /register-built Listing passes every LRR check a reader using the Standard registry can verify", async () => { const seed = Uint8Array.from(Buffer.alloc(32, 31)); const privateKey = privateKeyFromSeed(seed); const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; @@ -86,13 +92,17 @@ test("a /register-built Listing verifies under a reader using the Standard regis assert.equal(response.status, 200, option.railId); const preview = await response.json() as { listing: { pipeline: Array<{ kind: string; parameters?: Record }>; acceptedRails: Array<{ railId: string }> }; - railResolution: { disposition: string; authorityBasis: string }; + railResolution: { disposition: string; reason: string; authorityBasis: string }; }; assert.deepEqual(preview.listing.acceptedRails, [{ railId: option.railId }]); assert.deepEqual(preview.listing.pipeline.find((phase) => phase.kind === option.phaseKind)?.parameters, { rail: option.railId }); - assert.equal(preview.railResolution.authorityBasis, "pa1-in-code"); + assert.deepEqual(preview.railResolution, { + disposition: "indeterminate", reason: "pa1-definition-unverifiable", authorityBasis: "pa1-in-code", + }, option.railId); + // Every LRR check passes against the Standard's own table except the missing snapshot signature. const independent = resolveAgainstStandard(preview.listing); - assert.equal(independent.disposition, "verified", `${option.railId}: ${independent.reason}`); + assert.equal(independent.disposition, "indeterminate", `${option.railId}: ${independent.reason}`); + assert.equal(independent.reason, "pa1-definition-unverifiable", option.railId); // The handler name the Directory used to emit as a rail ID is not in the registry. const handlerAsRail = resolveAgainstStandard({ pipeline: [{ kind: option.phaseKind, parameters: { rail: option.phaseKind } }], @@ -113,3 +123,18 @@ test("the catalog projection keeps every registry rail ID for the §6.3.6 rail f }); assert.deepEqual(presentation.rails, registry.rails.map((rail) => rail.railId)); }); + +test("the publisher never asserts a PA-1 signature it did not verify, and refuses every other rail result", () => { + for (const definition of PUBLISHER_IN_CODE_RAIL_DEFINITIONS) assert.equal(definition.signatureValid, false, definition.railId); + assert.equal(railAuthorityPermitsPublication({ disposition: "indeterminate", reason: "pa1-definition-unverifiable" }), true); + assert.equal(railAuthorityPermitsPublication({ disposition: "verified", reason: "verified-pa1" }), true); + for (const result of [ + { disposition: "rejected", reason: "unknown-rail" }, + { disposition: "rejected", reason: "phase-handler-mismatch" }, + { disposition: "indeterminate", reason: "pa1-not-accepted" }, + { disposition: "indeterminate", reason: "registry-unavailable" }, + { disposition: "indeterminate" }, + ]) { + assert.equal(railAuthorityPermitsPublication(result), false, JSON.stringify(result)); + } +}); diff --git a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts index 16d0b70..9b2664e 100644 --- a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts +++ b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; +import { PUBLISHABLE_RAIL_OPTIONS } from "../src/catalog/listingOptions.js"; import { sdkIdentityPresentationVerifier, sdkListingSignatureVerifier, @@ -72,7 +72,11 @@ function readerDeps(): ListingValidationDeps { trustPhase: "PA-1", trustPolicyAcceptsPA1: true, registry: { state: "not-used", entries: [], definitions: [] }, - inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + // A reader whose trust policy holds a verified, signed PA-1 snapshot (LRR-6). + inCodeDefinitions: PUBLISHABLE_RAIL_OPTIONS.map((rail) => ({ + railId: rail.railId, railVersion: rail.railVersion, phaseHandler: rail.phaseKind, + governanceAnchoring: "in-code", signatureValid: true, + })), }), resolvePayloadVerificationCapability: () => ({ disposition: "unsupported", reason: "not configured" }), verifySellerControl: ({ bundle, signer }) => signer === bundle.presentedBy, From 30b4d42dfe1588561c857436045be7b061850a5d Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:23 +0000 Subject: [PATCH 23/37] Report listing anchors as included, never final, from a pinned node The Directory does not verify Demos consensus (validator signatures, eligible pool, quorum), so it no longer labels a write final: - listing receipts state `included` and the result carries finality: "not-verified"; the admission gate refuses a `finalized` claim - confirm-listing answers included-and-verified, inclusion-pending or inclusion-failed, and /register shows "included (finality not verified)" - receipts come only from the pinned deployment: the chain identity must match DEMOS_CHAIN_IDENTITY (default: node2's chain) and the node's reported build must be in DEMOS_NODE_BUILDS (default: the clean 61b8e75 build). A dirty build is refused unless that exact build is pinned as +dirty@ Tests cover the genesis and block-one identity paths, every validator signature entry shape, metadata agreement, by-hash writes elsewhere, and the deployment pin. The README's publication row also states the previous commit's unsigned rail-table result. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 14 +- .../app/api/dacs/confirm-listing/route.ts | 23 +- .../dacs-directory/app/register/page.tsx | 10 +- .../dacs-directory/e2e/register.spec.ts | 7 +- .../src/catalog/listingFinality.ts | 73 ++++-- .../src/catalog/nodeDeployment.ts | 44 ++++ .../test/confirm-listing.test.ts | 216 +++++++++++++++++- .../test/listing-admission.test.ts | 12 +- 8 files changed, 335 insertions(+), 64 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 296bcf7..f47ef27 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -24,7 +24,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` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`; the pinned Standard publishes no signed v0.1 snapshot, so this is not a canonically anchored result), 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 finalized `AnchorReceipt` in the pinned SDK's portable Demos form (the write is read back by hash, and finality is the BFT confirmation the configured Demos node reports; the Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum), and independently verifies native address, owner, program name, tuple, identity, signature and content hash before the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | +| Seller publication | DACS-1 §6.3.4 | `/register` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`). LRR-6 accepts only a signed snapshot, and the pinned Standard publishes no signed v0.1 snapshot, so the table is passed as unsigned and the result is `indeterminate` (`pa1-definition-unverifiable`). The publisher accepts only that result, in which every other LRR check passed, surfaces it as `railResolution`, and does not claim LP-6 `verified`. Any `rejected` or other indeterminate result refuses publication, 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 an `AnchorReceipt` in the pinned SDK's portable Demos form with state `included`, shown as "included (finality not verified)" (the write is read back by hash, and the block is one the configured Demos node reports as confirmed; the Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum, so it never labels the write final), and independently verifies native address, owner, program name, tuple, identity, signature and content hash before the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | | 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 | @@ -119,13 +119,15 @@ checks a clean checkout of that commit) and the SDK's `package.json` declares th | Variable | Required | Purpose | |---|---|---| | `DEMOS_RPC` | No | Demos RPC base URL; defaults to the public testnet endpoint | +| `DEMOS_CHAIN_IDENTITY` | No | Chain the listing receipts are pinned to (block one's `previousHash`); defaults to `83082fbb…7dd4`, the chain node2 serves. A node on another chain issues no receipt | +| `DEMOS_NODE_BUILDS` | No | Comma-separated node builds that may issue listing receipts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. node2 currently reports a dirty build, so `/register` refuses its receipts until it is rebuilt clean or the operator pins that exact build | | `NEXT_PUBLIC_DEMOS_RPC` | No | Credential-free public HTTPS Demos RPC advertised to machine clients; defaults to the public testnet endpoint and deliberately does not inherit `DEMOS_RPC` | | `DACS_ADMIN_TOKEN` | Production | Bearer token for the operational reindex endpoints | | `DACS_SDK_GITHUB_TOKEN` | GitHub deploy | Fine-grained, read-only token for cloning the pinned private SDK during the build; not needed after the SDK is published | | `DACS_DIRECTORY_DATA` | No | Writable directory for the SQLite repository and legacy JSON migration inputs | | `DACS_SCAN_MAX_TXS` | No | Maximum transactions scanned per pass; defaults to `100000` and fails closed if insufficient | | `DACS_SCAN_FINALITY_DEPTH` | No | Newest transaction count held back before indexing; defaults to `2` | -| `DACS_SCAN_REPLAY_DEPTH` | No | Finalized transaction overlap replayed on every pass; defaults to `2` | +| `DACS_SCAN_REPLAY_DEPTH` | No | Overlap of already-scanned transactions (behind the held-back depth) replayed on every pass; defaults to `2` | | `DACS_INDEX_INTERVAL_SECONDS` | No | Seconds between production reindex passes; defaults to `900` | | `DACS_CURSOR_STALL_SECONDS` | No | Cursor-stall alert threshold; defaults to twice the valid index interval (minimum `300`, default `1800`, maximum `86400`) | | `DACS_REACHABILITY_MAX_PROBES` | No | Maximum due listing surfaces probed per reindex; defaults to `20` (bounded to `1..100`) | @@ -271,8 +273,12 @@ base64url-patched Buffer). 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. That wait protects this browser flow + for canonical transaction inclusion in a block the configured node reports as confirmed, + a portable `AnchorReceipt` with state `included` ("included (finality not verified)"), and + independent native readback. The Directory never labels a write final or settled on + inclusion alone: verifying Demos finality (validator signatures, eligible pool, 2/3+1 + quorum) needs SDK support, and the pinned SDK's Demos decoder reads only `finalized` + receipts, so these receipts are not SDK finality evidence. That wait protects this browser flow only: registrations do not carry the receipt, `POST /api/dacs/register` accepts pointer sets from any client, and catalog admission rests on the indexer re-reading and re-authenticating each anchor rather than on transaction finality. A reload re-verifies that same anchor 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 9bb6f4a..e1cd1d6 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,8 +2,10 @@ * POST /api/dacs/confirm-listing — independently read and verify a listing * after the wallet broadcasts its StorageProgram transaction. * - * Visibility alone is insufficient: the canonical transaction and its - * BFT-confirmed block first produce a portable finalized AnchorReceipt. The + * Visibility alone is insufficient: the canonical transaction and the block + * the configured node reports as confirmed first produce a portable + * `included` AnchorReceipt. Demos finality is not verified (validator + * signatures and quorum need SDK support), so nothing here is labelled final. 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. @@ -11,7 +13,7 @@ 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 { includedListingAnchorReceipt } from "@/src/catalog/listingFinality"; import { verifyListing } from "@/src/catalog/listingVerification"; import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security"; @@ -81,7 +83,7 @@ export async function POST(req: NextRequest) { body.listingId, Number(body.listingVersion), ); - const finality = await finalizedListingAnchorReceipt({ + const inclusion = await includedListingAnchorReceipt({ anchorAddress: body.anchorAddress, logicalAddress, programName: body.programName, @@ -91,27 +93,28 @@ export async function POST(req: NextRequest) { ? body.transactionRef.toLowerCase() : undefined, }); - if (finality.status === "pending") { + if (inclusion.status === "pending") { return NextResponse.json( - { confirmed: false, state: "finality-pending", reason: finality.reason }, + { confirmed: false, state: "inclusion-pending", reason: inclusion.reason }, { status: 202 }, ); } - if (finality.status === "rejected") { + if (inclusion.status === "rejected") { return NextResponse.json( - { confirmed: false, state: "finality-failed", error: finality.reason }, + { confirmed: false, state: "inclusion-failed", error: inclusion.reason }, { status: 409 }, ); } return NextResponse.json({ confirmed: true, - state: "finalized-and-verified", + state: "included-and-verified", + finality: inclusion.finality, anchorAddress: body.anchorAddress, contentHash: verified.contentHash, sellerClaim: verified.sellerClaim, listingId: body.listingId, listingVersion: body.listingVersion, - anchorReceipt: finality.receipt, + anchorReceipt: inclusion.receipt, }); } diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index de4ea0c..ac0c934 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -169,7 +169,7 @@ export default function Register() { setStatus("Waiting for the exact signed listing to become readable and independently verifiable…"); for (let attempt = 0; attempt < 20; attempt++) { if (attempt > 0 && attempt % 4 === 0) { - setStatus(`The listing is still finalising on-chain. Verification check ${attempt + 1} of 20; no new transaction will be sent.`); + setStatus(`The listing is not yet included on-chain. Verification check ${attempt + 1} of 20; no new transaction will be sent.`); } const response = await fetch("/api/dacs/confirm-listing", { method: "POST", @@ -206,7 +206,7 @@ export default function Register() { } onStep?.("registering"); setPublishStep("registering"); - setStatus("One final wallet signature connects this finalized, authenticated catalog candidate to the directory."); + setStatus("One final wallet signature connects this included (finality not verified), authenticated catalog candidate to the directory."); const prepared = await fetch("/api/dacs/prepare-registration", { method: "POST", headers: { "content-type": "application/json" }, @@ -239,7 +239,7 @@ export default function Register() { clearPendingListingPublication(window.localStorage); setPendingPublication(null); setPublishStep("complete"); - 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."); + setStatus("Your signed listing is included on-chain (finality not verified), 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"); }; @@ -370,7 +370,7 @@ export default function Register() { ) 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."); + throw new Error("The listing builder returned incomplete recovery coordinates."); } const { ownerSignature: _ownerSignature, ...unsignedRegistration } = publication.registration; const signedListing = publication.listing; @@ -398,7 +398,7 @@ export default function Register() { } if (!publication.exists) { - if (!transaction) throw new Error("The finalized listing builder returned no anchor transaction."); + if (!transaction) throw new Error("The 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); diff --git a/reference-implementations/dacs-directory/e2e/register.spec.ts b/reference-implementations/dacs-directory/e2e/register.spec.ts index 11d4ebd..b31d977 100644 --- a/reference-implementations/dacs-directory/e2e/register.spec.ts +++ b/reference-implementations/dacs-directory/e2e/register.spec.ts @@ -88,8 +88,9 @@ test("seller publication survives registration failure and reload without rebroa confirmationPosts++; return json(route, { confirmed: true, - state: "finalized-and-verified", - anchorReceipt: { receiptVersion: "1", state: "finalized", transactionRef: { kind: "demos-storage-program", value: "cd".repeat(32) } }, + state: "included-and-verified", + finality: "not-verified", + anchorReceipt: { receiptVersion: "1", state: "included", transactionRef: { kind: "demos-storage-program", value: "cd".repeat(32) } }, }); }); await context.route("**/api/dacs/prepare-registration", (route) => json(route, { @@ -145,7 +146,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(/finalized anchor.*authenticated as a catalog candidate/)).toBeVisible(); + await expect(page.getByText(/included on-chain \(finality not verified\).*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/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts index ffca736..e99e969 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -8,6 +8,7 @@ import { import { isAnchorReceipt, type AnchorReceipt } from "@kynesyslabs/dacs/artifacts"; import { readAnchorRecord } from "./chain.js"; +import { nodeBuildRejection, pinnedChainIdentity } from "./nodeDeployment.js"; import { storageWriteCandidate } from "./scan.js"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); @@ -132,10 +133,15 @@ async function findCanonicalWrite( throw new Error(`finality lookup exceeded ${MAX_TRANSACTIONS} transactions`); } -export type FinalizedListingAnchorResult = +/** + * Inclusion is all the Directory can establish: it does not verify Demos + * consensus (validator signatures, eligible pool, quorum), so it never labels + * a write final. Verified Demos finality needs SDK support. + */ +export type ListingAnchorInclusionResult = | { status: "pending"; reason: string } | { status: "rejected"; reason: string } - | { status: "finalized"; receipt: AnchorReceipt }; + | { status: "included"; finality: "not-verified"; receipt: AnchorReceipt }; export interface ExpectedListingAnchorWrite { anchorAddress: string; @@ -147,16 +153,18 @@ export interface ExpectedListingAnchorWrite { /** * Registration admits a Listing only on a receipt that passes the SDK's CORE - * §5.1 AnchorReceipt gate, records an established finalized write, and binds - * this exact listing write. Returns the rejection reason, or null to admit. + * §5.1 AnchorReceipt gate, records an established included write, and binds + * this exact listing write. A `finalized` receipt is refused: this flow cannot + * verify Demos finality, so such a claim would be unverified. Returns the + * rejection reason, or null to admit. */ export function listingAnchorReceiptRejection( receipt: unknown, expected: ExpectedListingAnchorWrite, ): string | null { if (!isAnchorReceipt(receipt)) return "anchor receipt is not a CORE §5.1 AnchorReceipt"; - if (receipt.state !== "finalized" || receipt.observationDisposition !== "established") { - return "anchor receipt does not establish a finalized write"; + if (receipt.state !== "included" || receipt.observationDisposition !== "established") { + return "anchor receipt does not establish an included write"; } if ( receipt.logicalAddress !== expected.logicalAddress || @@ -169,19 +177,20 @@ export function listingAnchorReceiptRejection( } /** - * Establish a finalized Demos AnchorReceipt from the canonical transaction, - * the block the configured node reports as BFT-confirmed, and exact native - * readback. Read visibility alone never returns `finalized`. Consensus - * signatures are not independently verified (see validatorSignatureShape). + * Establish an `included` Demos AnchorReceipt from the canonical transaction, + * the block the configured node reports as confirmed, and exact native + * readback. Read visibility alone never returns `included`, and nothing here + * returns `finalized`: consensus signatures are not verified (see + * validatorSignatureShape). */ -export async function finalizedListingAnchorReceipt(input: { +export async function includedListingAnchorReceipt(input: { anchorAddress: string; logicalAddress: string; programName: string; listingContentHash: string; sellerClaim: string; transactionRef?: string; -}): Promise { +}): Promise { try { const anchored = await readAnchorRecord(input.anchorAddress); if (!anchored) return { status: "pending", reason: "native anchor is not readable" }; @@ -194,7 +203,7 @@ export async function finalizedListingAnchorReceipt(input: { input.listingContentHash, input.transactionRef, ); - if (!tx) return { status: "pending", reason: "canonical transaction is not finalized" }; + if (!tx) return { status: "pending", reason: "canonical transaction is not included" }; const txRef = typeof tx.hash === "string" ? tx.hash.toLowerCase() : ""; const blockNumber = Number(tx.blockNumber); const txContent = record(tx.content); @@ -215,7 +224,15 @@ export async function finalizedListingAnchorReceipt(input: { typeof block.hash !== "string" || !/^[0-9a-f]{64}$/.test(block.hash) || !Number.isSafeInteger(blockTimestamp) || !orderedTransactions.includes(txRef) || !validatorSignatureShape(block.validation_data) - ) return { status: "pending", reason: "canonical block is not BFT-confirmed" }; + ) return { status: "pending", reason: "canonical block is not confirmed by the configured node" }; + + // Only the pinned deployment's report becomes a portable receipt. + const buildRejection = nodeBuildRejection(await nodeCall("getNetworkInfo", {})); + if (buildRejection) return { status: "rejected", reason: buildRejection }; + const servedChain = await chainIdentity(); + if (servedChain !== pinnedChainIdentity()) { + return { status: "rejected", reason: `Demos node serves chain ${servedChain}, not the pinned chain` }; + } const payload = Array.isArray(txContent.data) ? record(txContent.data[1]) : null; const transactionMetadata = record(payload?.metadata); @@ -226,7 +243,7 @@ export async function finalizedListingAnchorReceipt(input: { const finalityProof = canonicalize(block.validation_data); const evidence: DemosWriteEvidence = { evidenceVersion: "1", - chainIdentity: await chainIdentity(), + chainIdentity: servedChain, writer: owner, logicalName: input.logicalAddress, nativeAddress: input.anchorAddress, @@ -256,18 +273,25 @@ export async function finalizedListingAnchorReceipt(input: { metadata.envelopeHash !== nativeValueHash ) return { status: "rejected", reason: "native listing metadata does not bind the signed artifact" }; assertDemosWriteEvidence(evidence); - const receipt = demosWriteEvidenceToAnchorReceipt({ - logicalAddress: input.logicalAddress, - contentHash: input.listingContentHash, - writer: input.sellerClaim, - evidence, - }); + // The SDK's portable form always states `finalized`; without consensus + // verification the Directory can establish only inclusion (CORE §5.1). + const receipt: AnchorReceipt = { + ...demosWriteEvidenceToAnchorReceipt({ + logicalAddress: input.logicalAddress, + contentHash: input.listingContentHash, + writer: input.sellerClaim, + evidence, + }), + state: "included", + }; const rejection = listingAnchorReceiptRejection(receipt, { ...input, transactionRef: txRef }); - return rejection ? { status: "rejected", reason: rejection } : { status: "finalized", receipt }; + return rejection + ? { status: "rejected", reason: rejection } + : { status: "included", finality: "not-verified", receipt }; } catch (error) { return { status: "pending", - reason: error instanceof Error ? error.message : "finality observation failed", + reason: error instanceof Error ? error.message : "inclusion observation failed", }; } } @@ -277,8 +301,7 @@ export async function finalizedListingAnchorReceipt(input: { * key to Ed25519 block signature. This refuses placeholder or arbitrary * objects, but it is a shape check only. The signatures, the eligible * validator pool and the 2/3+1 quorum are not verified here, nor by the - * pinned SDK's own Demos adapter, so a `finalized` receipt rests on the - * configured Demos node's report. + * pinned SDK's own Demos adapter, which is why receipts state `included`. */ function validatorSignatureShape(value: unknown): boolean { const signatures = record(record(value)?.signatures); diff --git a/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts b/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts new file mode 100644 index 0000000..1b63e90 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts @@ -0,0 +1,44 @@ +/** + * The Demos deployment this release was checked against. + * + * A node reports its own build, so this pin detects deployment drift (another + * chain, another build, uncommitted changes) rather than authenticating the + * node; consensus is still not verified (see listingFinality.ts). Operators + * re-pin with DEMOS_CHAIN_IDENTITY and DEMOS_NODE_BUILDS. + */ + +/** Block one's `previousHash` on the Demos network node2 serves (block zero answers `error`). */ +export const PINNED_DEMOS_CHAIN_IDENTITY = "83082fbb733d76475a33a9f52fe3700d667a976c2d46bef33a149f47057f7dd4"; + +/** demos-node-software 0.9.8, the commit node2 reports. */ +export const PINNED_DEMOS_NODE_BUILDS = ["61b8e75aa8db5e408c49e1c3a89db4d673f45578"]; + +export function pinnedChainIdentity(configured = process.env.DEMOS_CHAIN_IDENTITY): string { + return (configured?.trim() || PINNED_DEMOS_CHAIN_IDENTITY).toLowerCase(); +} + +/** + * `` admits that clean build. A build with uncommitted changes has no + * reviewable source, so it is refused unless the operator pins that exact + * observed build as `+dirty@`. + */ +export function pinnedNodeBuilds(configured = process.env.DEMOS_NODE_BUILDS): string[] { + const builds = configured?.split(",").map((build) => build.trim()).filter(Boolean) ?? []; + return builds.length > 0 ? builds : PINNED_DEMOS_NODE_BUILDS; +} + +const record = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + +/** Return why the node's reported build is not pinned, or null when it is. */ +export function nodeBuildRejection(networkInfo: unknown, builds = pinnedNodeBuilds()): string | null { + const version = record(record(networkInfo)?.nodeVersion); + const commit = typeof version?.commit === "string" ? version.commit : ""; + if (!/^[0-9a-f]{40}$/.test(commit) || typeof version?.dirty !== "boolean") { + return "Demos node reports no build identity"; + } + const build = version.dirty ? `${commit}+dirty@${String(version.builtAt ?? "")}` : commit; + return builds.includes(build) ? null : `Demos node build ${build} is not pinned`; +} diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 9dfc3b9..aa7ca95 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -7,9 +7,18 @@ import { canonicalize, contentHash, listingAddress, sha256Hex } from "@kynesysla import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; const { POST } = await import("../app/api/dacs/confirm-listing/route.js"); +const { PINNED_DEMOS_CHAIN_IDENTITY, nodeBuildRejection, pinnedChainIdentity, pinnedNodeBuilds } = await import( + "../src/catalog/nodeDeployment.js" +); const { decodeDemosAnchorReceiptProof, demosSignedTransactionProofHash } = await import( "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js" ); +/** + * The receipt carries the SDK's portable Demos proof, restated as `included`. + * The SDK decoder reads only `finalized` receipts, so decode the same proof. + */ +const proofOf = (receipt: unknown) => + decodeDemosAnchorReceiptProof({ ...(receipt as Record), state: "finalized" } as never); const privateKey = privateKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))); const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))))).toString("hex"); @@ -118,6 +127,10 @@ const historyRow = (tx: NodeTransaction) => ({ const byHashRow = ({ id: _id, type: _type, to: _to, ...tx }: NodeTransaction) => ({ ...tx, ed25519_signature: null }); const blockHash = "ef".repeat(32); const chainId = "83".repeat(32); +// The fixture chain stands in for the pinned Demos deployment. +process.env.DEMOS_CHAIN_IDENTITY = chainId; +const pinnedCommit = "61b8e75aa8db5e408c49e1c3a89db4d673f45578"; +const cleanBuild = { name: "demos-node-software", version: "0.9.8", commit: pinnedCommit, dirty: false }; const coordinates = { anchorAddress, programName, @@ -137,6 +150,14 @@ function stubChain(overrides: { transactionMetadata?: Record; block?: Record; history?: boolean; + /** Block zero's answer; by default node2's `result: 400` error. */ + genesis?: Record; + /** Members replacing block one's. */ + first?: Record; + /** Members replacing the getTxByHash row's. */ + byHash?: Record; + /** getNetworkInfo's `nodeVersion`; a clean build of the pinned commit by default. */ + nodeVersion?: Record | null; } = {}): string[] { const write = transaction.content.data[1] as Record; const written = overrides.transactionMetadata @@ -162,7 +183,7 @@ function stubChain(overrides: { } if (call.message === "getTxByHash") { return call.data.hash === transactionHash - ? Response.json({ result: 200, response: byHashRow(written) }) + ? Response.json({ result: 200, response: { ...byHashRow(written), ...overrides.byHash } }) : Response.json({ result: 404, response: "error" }); } if (call.message === "getBlockByNumber" && call.data.blockNumber === 5) { @@ -178,13 +199,19 @@ function stubChain(overrides: { }, }); } + if (call.message === "getNetworkInfo") { + const nodeVersion = overrides.nodeVersion === undefined ? cleanBuild : overrides.nodeVersion; + return Response.json({ result: 200, response: { forks: {}, ...(nodeVersion ? { nodeVersion } : {}) } }); + } if (call.message === "getBlockByNumber" && call.data.blockNumber === 0) { - return Response.json({ result: 400, response: "error", extra: "Block number not provided" }); + return overrides.genesis + ? Response.json({ result: 200, response: overrides.genesis }) + : Response.json({ result: 400, response: "error", extra: "Block number not provided" }); } if (call.message === "getBlockByNumber" && call.data.blockNumber === 1) { return Response.json({ result: 200, - response: { id: 2, number: 1, status: "confirmed", hash: "12".repeat(32), content: { previousHash: chainId, timestamp: 1 } }, + response: { id: 2, number: 1, status: "confirmed", hash: "12".repeat(32), content: { previousHash: chainId, timestamp: 1 }, ...overrides.first }, }); } return Response.json({ result: 500, response: null }); @@ -200,7 +227,8 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has assert.equal(response.status, 200); const body = await response.json() as Record; assert.equal(body.confirmed, true); - assert.equal(body.state, "finalized-and-verified"); + assert.equal(body.state, "included-and-verified"); + assert.equal(body.finality, "not-verified", "inclusion is never labelled final"); assert.equal(body.anchorAddress, anchorAddress); assert.equal(body.contentHash, listingHash); assert.equal(body.sellerClaim, sellerClaim); @@ -208,7 +236,7 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has assert.equal(body.listingVersion, 1); const receipt = body.anchorReceipt as Record; assert.equal(isAnchorReceipt(receipt), true); - assert.equal(receipt.state, "finalized"); + assert.equal(receipt.state, "included"); assert.equal(receipt.observationDisposition, "established"); assert.equal(receipt.logicalAddress, logicalAddress); assert.equal(receipt.nativeAddress, anchorAddress); @@ -216,7 +244,7 @@ test("listing confirmation verifies coordinates, owner, identity, signature, has assert.deepEqual(receipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); // node2 serves block timestamps in seconds; the receipt carries milliseconds. assert.deepEqual(receipt.blockRef, { id: blockHash, height: "5", timestamp: 1_786_360_000_000 }); - const proof = decodeDemosAnchorReceiptProof(receipt as never); + const proof = proofOf(receipt); assert.equal(proof.chainIdentity, chainId, "block zero answers `error`, so block one's predecessor names the chain"); // Portable: the SDK verifier re-reads getTxByHash, whose signature is an object. assert.equal(proof.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction))); @@ -235,10 +263,10 @@ test("listing confirmation reads a browser-supplied transactionRef by hash", asy const response = await POST(request({ ...coordinates, transactionRef })); assert.equal(response.status, 200, transactionRef); const body = await response.json() as { state: string; anchorReceipt: Record }; - assert.equal(body.state, "finalized-and-verified"); + assert.equal(body.state, "included-and-verified"); assert.deepEqual(body.anchorReceipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); assert.equal( - decodeDemosAnchorReceiptProof(body.anchorReceipt as never).signedTransactionHash, + proofOf(body.anchorReceipt).signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction)), ); assert.ok(calls.includes("getTxByHash")); @@ -247,7 +275,7 @@ test("listing confirmation reads a browser-supplied transactionRef by hash", asy stubChain(); const other = await POST(request({ ...coordinates, transactionRef: "ef".repeat(32) })); assert.equal(other.status, 202); - assert.equal((await other.json() as { state: string }).state, "finality-pending"); + assert.equal((await other.json() as { state: string }).state, "inclusion-pending"); const malformed = await POST(request({ ...coordinates, transactionRef: "not-a-hash" })); assert.equal(malformed.status, 400); } finally { @@ -255,7 +283,7 @@ test("listing confirmation reads a browser-supplied transactionRef by hash", asy } }); -test("listing confirmation never registers before the write is BFT-final", async () => { +test("listing confirmation never registers before the write is in a node-confirmed block", async () => { const originalFetch = globalThis.fetch; try { for (const block of [ @@ -274,7 +302,7 @@ test("listing confirmation never registers before the write is BFT-final", async assert.equal(response.status, 202, JSON.stringify(block)); const body = await response.json() as Record; assert.equal(body.confirmed, false); - assert.equal(body.state, "finality-pending"); + assert.equal(body.state, "inclusion-pending"); assert.equal(body.anchorReceipt, undefined); } } finally { @@ -295,7 +323,7 @@ test("listing confirmation rejects native metadata that does not bind the signed const response = await POST(request(coordinates)); assert.equal(response.status, 409, Object.keys(overrides).join("+")); const body = await response.json() as Record; - assert.equal(body.state, "finality-failed"); + assert.equal(body.state, "inclusion-failed"); assert.equal(body.anchorReceipt, undefined); } } finally { @@ -336,3 +364,167 @@ test("listing confirmation rejects valid bytes under the wrong expected content globalThis.fetch = originalFetch; } }); + +/** The receipt's chain identity when `pinned` is the pinned chain, or the confirmation state. */ +async function chainIdentityFor(overrides: Parameters[0], pinned = chainId): Promise { + stubChain(overrides); + process.env.DEMOS_CHAIN_IDENTITY = pinned; + try { + const response = await POST(request(coordinates)); + const body = await response.json() as { state: string; anchorReceipt?: unknown }; + return response.status === 200 ? proofOf(body.anchorReceipt).chainIdentity : body.state; + } finally { + process.env.DEMOS_CHAIN_IDENTITY = chainId; + } +} + +test("chain identity is a genesis block that answers as block zero, else a well-formed block one's predecessor", async () => { + const originalFetch = globalThis.fetch; + const genesisHash = "0a".repeat(32); + try { + assert.equal(await chainIdentityFor({ genesis: { number: 0, hash: ` ${genesisHash.toUpperCase()} ` } }, genesisHash), genesisHash); + assert.equal(await chainIdentityFor({ genesis: { id: 0, hash: genesisHash } }, genesisHash), genesisHash, "the node's `id` numbers the block"); + assert.equal(await chainIdentityFor({ genesis: { hash: genesisHash } }, genesisHash), genesisHash); + // A block that is not block zero, or has no hash, never names the chain. + assert.equal(await chainIdentityFor({ genesis: { number: 3, hash: genesisHash } }), chainId); + assert.equal(await chainIdentityFor({ genesis: { number: 0, hash: " " } }), chainId); + for (const first of [ + { status: "pending" }, + { number: 2 }, + { hash: undefined }, + { hash: " " }, + { content: { timestamp: 1 } }, + { content: JSON.stringify({ previousHash: "", timestamp: 1 }) }, + ]) { + assert.equal(await chainIdentityFor({ first }), "inclusion-pending", JSON.stringify(first)); + } + // node2 may serve block one's content as a JSON string. + assert.equal(await chainIdentityFor({ first: { content: JSON.stringify({ previousHash: chainId, timestamp: 1 }) } }), chainId); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("every validator-signature entry must have the node's key and signature shape", async () => { + const originalFetch = globalThis.fetch; + const key = `0x${"aa".repeat(32)}`; + const signature = `0x${"bb".repeat(64)}`; + try { + for (const [name, signatures] of [ + ["a malformed entry beside a good one", { [key]: signature, [`0x${"cc".repeat(32)}`]: "0x1234" }], + ["a 63-hex key", { [`0x${"a".repeat(63)}`]: signature }], + ["a 65-byte key", { [`0x${"a".repeat(66)}`]: signature }], + ["a key without 0x", { ["aa".repeat(32)]: signature }], + ["a 127-hex signature", { [key]: `0x${"b".repeat(127)}` }], + ["a 129-hex signature", { [key]: `0x${"b".repeat(129)}` }], + ["a non-string signature", { [key]: 7 }], + ] as const) { + stubChain({ block: { validation_data: { signatures } } }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 202, name); + assert.equal((await response.json() as { state: string }).state, "inclusion-pending", name); + } + // Several well-formed entries, in either hex case, keep the node's shape. + stubChain({ block: { validation_data: { signatures: { [key]: signature, [`0x${"CC".repeat(32)}`]: `0x${"DD".repeat(64)}` } } } }); + assert.equal((await POST(request(coordinates))).status, 200); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("native and transaction metadata must agree and bind the logical address, content and envelope", async () => { + const originalFetch = globalThis.fetch; + try { + for (const [name, overrides] of [ + ["transaction metadata differs", { transactionMetadata: { ...metadata, note: "other" } }], + ["native metadata missing", { nativeMetadata: undefined as never, transactionMetadata: metadata, dropNative: true }], + ["another logical address", { nativeMetadata: { ...metadata, logicalAddress: "dacs1:other" }, transactionMetadata: { ...metadata, logicalAddress: "dacs1:other" } }], + ["another content hash", { nativeMetadata: { ...metadata, contentHash: "0".repeat(64) }, transactionMetadata: { ...metadata, contentHash: "0".repeat(64) } }], + ] as const) { + const { dropNative, ...stub } = overrides as Parameters[0] & { dropNative?: boolean }; + stubChain(stub); + if (dropNative) { + const stubbed = globalThis.fetch; + globalThis.fetch = async (input, init) => init?.method === "POST" ? stubbed(input, init) + : Response.json({ success: true, data: listing, owner, programName, storageAddress: anchorAddress }); + } + const response = await POST(request(coordinates)); + assert.equal(response.status, 409, name); + assert.equal((await response.json() as { state: string }).state, "inclusion-failed", name); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a by-hash write at another locator or with other content never stands in for this listing", async () => { + const originalFetch = globalThis.fetch; + const other = `stor-${"cd".repeat(20)}`; + const write = transaction.content.data[1] as Record; + try { + for (const [name, content] of [ + ["another locator", { ...transaction.content, to: other, data: ["storageProgram", { ...write, storageAddress: other }] }], + ["other listing bytes", { ...transaction.content, data: ["storageProgram", { ...write, data: { ...listing, terms: { other: true } } }] }], + ] as const) { + stubChain({ history: false, byHash: { content } }); + const response = await POST(request({ ...coordinates, transactionRef: transactionHash })); + assert.equal(response.status, 202, name); + assert.equal((await response.json() as { state: string }).state, "inclusion-pending", name); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("only the pinned Demos deployment's report becomes a receipt", async () => { + const originalFetch = globalThis.fetch; + const node2 = { ...cleanBuild, dirty: true, builtAt: "2026-10-14T16:45:24Z" }; + const confirm = async (overrides: Parameters[0]) => { + stubChain(overrides); + const response = await POST(request(coordinates)); + return { status: response.status, body: await response.json() as { state: string; error?: string } }; + }; + try { + for (const [name, nodeVersion, reason] of [ + ["a build with uncommitted changes", node2, /\+dirty@2026-10-14T16:45:24Z is not pinned/], + ["another commit", { ...cleanBuild, commit: "0".repeat(40) }, /is not pinned/], + ["no dirty flag", { ...cleanBuild, dirty: undefined }, /no build identity/], + ["no build identity", null, /no build identity/], + ] as const) { + const { status, body } = await confirm({ nodeVersion }); + assert.equal(status, 409, name); + assert.equal(body.state, "inclusion-failed", name); + assert.match(body.error ?? "", reason, name); + } + // An operator may pin exactly one observed dirty build, never dirty builds in general. + process.env.DEMOS_NODE_BUILDS = `${pinnedCommit}+dirty@2026-10-14T16:45:24Z`; + assert.equal((await confirm({ nodeVersion: node2 })).status, 200); + assert.equal((await confirm({ nodeVersion: { ...node2, builtAt: "2026-10-15T00:00:00Z" } })).status, 409); + assert.equal((await confirm({})).status, 409, "the clean build is not in an explicit list that omits it"); + delete process.env.DEMOS_NODE_BUILDS; + // A node on another chain never issues a receipt for the pinned one. + process.env.DEMOS_CHAIN_IDENTITY = "aa".repeat(32); + const otherChain = await confirm({}); + assert.equal(otherChain.status, 409); + assert.match(otherChain.body.error ?? "", /not the pinned chain/); + } finally { + delete process.env.DEMOS_NODE_BUILDS; + process.env.DEMOS_CHAIN_IDENTITY = chainId; + globalThis.fetch = originalFetch; + } +}); + +test("the release pins node2's chain and clean build by default", () => { + delete process.env.DEMOS_CHAIN_IDENTITY; + try { + assert.equal(pinnedChainIdentity(), "83082fbb733d76475a33a9f52fe3700d667a976c2d46bef33a149f47057f7dd4"); + } finally { + process.env.DEMOS_CHAIN_IDENTITY = chainId; + } + assert.equal(pinnedChainIdentity(" "), PINNED_DEMOS_CHAIN_IDENTITY); + assert.deepEqual(pinnedNodeBuilds(undefined), [pinnedCommit]); + assert.deepEqual(pinnedNodeBuilds(" , "), [pinnedCommit]); + assert.deepEqual(pinnedNodeBuilds(`${pinnedCommit}, ${"1".repeat(40)}`), [pinnedCommit, "1".repeat(40)]); + assert.equal(nodeBuildRejection({ nodeVersion: { commit: pinnedCommit.toUpperCase(), dirty: false } }, [pinnedCommit]), + "Demos node reports no build identity"); +}); diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index f8586a6..55c06ec 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -202,7 +202,7 @@ test("published summaries never claim transaction readiness the Directory did no ); }); -test("registration admits only an SDK-shaped, established, finalized receipt for this exact write", () => { +test("registration admits only an SDK-shaped, established, included receipt for this exact write", () => { const listingContentHash = "d".repeat(64); const expected = { anchorAddress: `stor-${"5".repeat(40)}`, @@ -221,7 +221,7 @@ test("registration admits only an SDK-shaped, established, finalized receipt for transactionRef: { kind: "demos-storage-program", value: expected.transactionRef }, writer: seller, nonce: "7", - state: "finalized", + state: "included", observationDisposition: "established", observedAt: 1_786_360_000_000, blockRef: { id: "block-5", height: "5", timestamp: 1_786_360_000_000 }, @@ -235,11 +235,13 @@ test("registration admits only an SDK-shaped, established, finalized receipt for const rejections: Record = { "missing evidence": [withoutEvidence, /not a CORE §5\.1 AnchorReceipt/], "unknown member": [{ ...receipt, trustLevel: "high" }, /not a CORE §5\.1 AnchorReceipt/], - "finalized without a block": [withoutBlock, /not a CORE §5\.1 AnchorReceipt/], - "included, not finalized": [{ ...receipt, state: "included" }, /does not establish a finalized write/], + "included without a block": [withoutBlock, /not a CORE §5\.1 AnchorReceipt/], + // Demos finality is not verified, so a receipt claiming it is never admitted. + "finalized claim": [{ ...receipt, state: "finalized" }, /does not establish an included write/], + "only accepted": [{ ...receipt, state: "accepted" }, /does not establish an included write/], "indeterminate observation": [ { ...receipt, observationDisposition: "indeterminate", preservedReceiptHash: "f".repeat(64) }, - /does not establish a finalized write/, + /does not establish an included write/, ], "another logical address": [{ ...receipt, logicalAddress: listingAddress(seller, "receipt-listing", 2) }, /does not bind/], "another native address": [{ ...receipt, nativeAddress: `stor-${"6".repeat(40)}` }, /does not bind/], From e66e67eb371ade129bb7ed290e4d52b07d0875dd Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:25:23 +0000 Subject: [PATCH 24/37] Authenticate the packaged SDK build by its digest The revision marker only named a commit. setup-sdk.sh now pins SDK_DIST_SHA256, the digest of the dist that SDK_REV builds to (reproducible), and checks it for a packaged build and after a source build, which also records a per-file dist/.dacs-sdk-manifest. Regular files only: a symlink in dist is refused. setup-sdk.test covers the packaged path (marker, Standard revision, changed bytes, extra file, symlink) and, with stub git and npm on PATH, the source path (HEAD, dirty tree, Standard revision, stale marker, divergent build output). It also checks the pin against the vendored build when one is present. Co-Authored-By: Claude Opus 5.5 --- docs/codex-local-workflow.md | 3 +- .../dacs-directory/README.md | 7 +- .../scripts/sdk-dist-digest.mjs | 43 ++++++ .../dacs-directory/scripts/setup-sdk.sh | 22 ++- .../dacs-directory/test/setup-sdk.test.mjs | 131 +++++++++++++++--- 5 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 6143128..67139fc 100644 --- a/docs/codex-local-workflow.md +++ b/docs/codex-local-workflow.md @@ -92,7 +92,8 @@ stops at that check: [ "$(git -C "$sdk" rev-parse HEAD)" = "$sdk_rev" ] || die "$sdk is not at $sdk_rev" (cd "$sdk" && npm ci --no-audit --no-fund && npm run build) - # scripts/setup-sdk.sh accepts a packaged dist only with this marker. + # scripts/setup-sdk.sh accepts a packaged dist only with this marker, and + # only when its bytes also hash to the pinned SDK_DIST_SHA256. printf '%s' "$sdk_rev" > "$sdk/dist/.dacs-sdk-revision" npm ci --no-audit --no-fund ) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index f47ef27..16342b5 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -111,8 +111,11 @@ railway up . --no-gitignore `.railwayignore` includes only the SDK's compiled `dist` output from the otherwise ignored vendor directory. `scripts/setup-sdk.sh` accepts that packaged build only when `dist/.dacs-sdk-revision` names the pinned `SDK_REV` (written by the source build after it -checks a clean checkout of that commit) and the SDK's `package.json` declares the pinned -`dacs.standardRevision`; otherwise it refuses to continue. +checks a clean checkout of that commit), the SDK's `package.json` declares the pinned +`dacs.standardRevision`, and the `dist` bytes hash to the pinned `SDK_DIST_SHA256` +(`scripts/sdk-dist-digest.mjs`; the SDK build is reproducible, and a source build is held to +the same digest and records its per-file `dist/.dacs-sdk-manifest`); otherwise it refuses to +continue. Moving `SDK_REV` therefore also means rebuilding once and updating `SDK_DIST_SHA256`. ### Configuration diff --git a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs new file mode 100644 index 0000000..6175257 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Digest of a built dacs-sdk `dist` tree, so setup can pin the exact bytes the +// pinned commit builds to. Each regular file contributes " \n" +// in byte order of its POSIX relative path; the digest is the sha256 of those +// lines. Setup's own top-level `.dacs-sdk-*` files are excluded, and any other +// entry type (a symlink, for example) is refused. +// +// usage: node scripts/sdk-dist-digest.mjs [--manifest ] +import { createHash } from "node:crypto"; +import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export function distManifest(root) { + const files = []; + const walk = (directory, prefix) => { + for (const name of readdirSync(directory)) { + const path = join(directory, name); + const relative = prefix ? `${prefix}/${name}` : name; + const entry = lstatSync(path); + if (entry.isDirectory()) walk(path, relative); + else if (entry.isFile()) { + if (!(prefix === "" && name.startsWith(".dacs-sdk-"))) files.push(relative); + } else throw new Error(`${relative} is not a regular file or directory`); + } + }; + walk(root, ""); + if (files.length === 0) throw new Error(`${root} contains no files`); + files.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + return files.map((file) => `${createHash("sha256").update(readFileSync(join(root, file))).digest("hex")} ${file}\n`).join(""); +} + +export const distDigest = (root) => createHash("sha256").update(distManifest(root)).digest("hex"); + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + const [root, flag, out] = process.argv.slice(2); + if (!root || (flag !== undefined && (flag !== "--manifest" || !out))) { + console.error("usage: node scripts/sdk-dist-digest.mjs [--manifest ]"); + process.exit(2); + } + const manifest = distManifest(root); + if (out) writeFileSync(out, manifest); + process.stdout.write(`${createHash("sha256").update(manifest).digest("hex")}\n`); +} diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index 38db2b0..a773c7d 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -27,6 +27,11 @@ git_with_sdk_auth() { # dacs.standardRevision); the Directory conforms to the same revision. STANDARD_REV="d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20" SDK_REV_MARKER="vendor/dacs-sdk/dist/.dacs-sdk-revision" +SDK_MANIFEST="vendor/dacs-sdk/dist/.dacs-sdk-manifest" +# scripts/sdk-dist-digest.mjs over the dist that SDK_REV builds to (`npm ci` +# then `npm run build`; the build is reproducible). A packaged or freshly built +# dist is accepted only with exactly these bytes. +SDK_DIST_SHA256="eda8a1f62194e8e8d0b4815770a7af4056d6934a0fd82150b207daa0e3ace2d2" require_standard_revision() { local declared @@ -37,14 +42,24 @@ require_standard_revision() { fi } +require_pinned_dist() { + local digest + digest="$(node scripts/sdk-dist-digest.mjs vendor/dacs-sdk/dist "$@")" + if [ "$digest" != "$SDK_DIST_SHA256" ]; then + echo "vendor/dacs-sdk/dist has digest ${digest}, not the pinned build ${SDK_DIST_SHA256}" >&2 + exit 1 + fi +} + if [ -d vendor/dacs-sdk/dist ] && [ ! -d vendor/dacs-sdk/.git ]; then - # A packaged build is accepted only when the source build below produced it - # from the pinned commit. + # A packaged build is accepted only when its bytes are the pinned build of + # the pinned commit; the marker alone is not evidence. if [ "$(cat "$SDK_REV_MARKER" 2>/dev/null)" != "$SDK_REV" ]; then echo "packaged dacs-sdk build is not marked as built from ${SDK_REV}; refusing it" >&2 exit 1 fi require_standard_revision + require_pinned_dist echo "using packaged dacs-sdk build of ${SDK_REV}" else if [ ! -d vendor/dacs-sdk ]; then @@ -58,8 +73,9 @@ else exit 1 fi require_standard_revision - rm -f "$SDK_REV_MARKER" + rm -f "$SDK_REV_MARKER" "$SDK_MANIFEST" (cd vendor/dacs-sdk && npm ci --no-audit --no-fund && npm run build) + require_pinned_dist --manifest "$SDK_MANIFEST" printf '%s' "$SDK_REV" > "$SDK_REV_MARKER" fi if [ "${DACS_SKIP_APP_INSTALL:-0}" != "1" ]; then diff --git a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs index e67a009..3f24f3c 100644 --- a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs +++ b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs @@ -1,46 +1,137 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { distDigest } from "../scripts/sdk-dist-digest.mjs"; const script = new URL("../scripts/setup-sdk.sh", import.meta.url); +const digestScript = new URL("../scripts/sdk-dist-digest.mjs", import.meta.url); const pinned = readFileSync(script, "utf8"); const sdkRev = pinned.match(/^SDK_REV="([0-9a-f]{40})"$/m)?.[1]; const standardRev = pinned.match(/^STANDARD_REV="([0-9a-f]{40})"$/m)?.[1]; +const distSha = pinned.match(/^SDK_DIST_SHA256="([0-9a-f]{64})"$/m)?.[1]; +const builtIndex = "export {};\n"; -/** Run setup against a packaged (no .git) vendor/dacs-sdk tree; never installs anything. */ -function setupWithPackagedSdk({ marker, standardRevision }) { +// The fixture dist stands in for the pinned build: the copied script pins its digest. +const fixtureDist = mkdtempSync(join(tmpdir(), "dacs-setup-dist-")); +writeFileSync(join(fixtureDist, "index.js"), builtIndex); +const fixtureSha = distDigest(fixtureDist); +rmSync(fixtureDist, { recursive: true, force: true }); + +/** Stub `git` and `npm`: the source path runs offline and records what it ran. */ +function stubBin(root) { + const bin = join(root, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "git"), `#!/usr/bin/env bash +echo "git $*" >> "$STUB_LOG" +case " $* " in + *" rev-parse "*) printf '%s\\n' "$STUB_HEAD" ;; + *" status "*) printf '%s' "\${STUB_STATUS:-}" ;; +esac +`); + writeFileSync(join(bin, "npm"), `#!/usr/bin/env bash +echo "npm $*" >> "$STUB_LOG" +if [ "$1" = run ] && [ "$2" = build ]; then + [ "\${STUB_BUILD_FAIL:-0}" = 1 ] && exit 1 + mkdir -p dist && printf '%s' "$STUB_BUILD_OUTPUT" > dist/index.js +fi +`); + chmodSync(join(bin, "git"), 0o755); + chmodSync(join(bin, "npm"), 0o755); + return bin; +} + +/** Run setup in a scratch app root; never installs or fetches anything. */ +function setup({ source = false, marker, standardRevision = standardRev, dist = { "index.js": builtIndex }, head = sdkRev, status = "", + buildFail = false, buildOutput = builtIndex, link }) { const root = mkdtempSync(join(tmpdir(), "dacs-setup-sdk-")); try { mkdirSync(join(root, "scripts")); - copyFileSync(script, join(root, "scripts/setup-sdk.sh")); - mkdirSync(join(root, "vendor/dacs-sdk/dist"), { recursive: true }); - writeFileSync(join(root, "vendor/dacs-sdk/dist/index.js"), "export {};\n"); - writeFileSync(join(root, "vendor/dacs-sdk/package.json"), JSON.stringify({ dacs: { standardRevision } })); - if (marker !== undefined) writeFileSync(join(root, "vendor/dacs-sdk/dist/.dacs-sdk-revision"), marker); - return spawnSync("bash", [join(root, "scripts/setup-sdk.sh")], { - env: { ...process.env, DACS_SKIP_APP_INSTALL: "1" }, + writeFileSync(join(root, "scripts/setup-sdk.sh"), pinned.replace(`SDK_DIST_SHA256="${distSha}"`, `SDK_DIST_SHA256="${fixtureSha}"`)); + copyFileSync(digestScript, join(root, "scripts/sdk-dist-digest.mjs")); + const sdk = join(root, "vendor/dacs-sdk"); + mkdirSync(join(sdk, source ? ".git" : "dist"), { recursive: true }); + if (!source) for (const [name, content] of Object.entries(dist)) writeFileSync(join(sdk, "dist", name), content); + if (link) symlinkSync(join(root, "outside.js"), join(sdk, "dist", link)); + writeFileSync(join(sdk, "package.json"), JSON.stringify({ dacs: { standardRevision } })); + if (marker !== undefined) { + mkdirSync(join(sdk, "dist"), { recursive: true }); + writeFileSync(join(sdk, "dist/.dacs-sdk-revision"), marker); + } + const log = join(root, "calls.log"); + writeFileSync(log, ""); + const result = spawnSync("bash", [join(root, "scripts/setup-sdk.sh")], { + env: { + ...process.env, DACS_SKIP_APP_INSTALL: "1", PATH: `${stubBin(root)}:${process.env.PATH}`, STUB_LOG: log, + STUB_HEAD: head, STUB_STATUS: status, STUB_BUILD_FAIL: buildFail ? "1" : "0", STUB_BUILD_OUTPUT: buildOutput, + }, encoding: "utf8", }); + const read = (path) => existsSync(join(sdk, path)) ? readFileSync(join(sdk, path), "utf8") : undefined; + return { ...result, calls: readFileSync(log, "utf8"), marker: read("dist/.dacs-sdk-revision"), manifest: read("dist/.dacs-sdk-manifest") }; } finally { rmSync(root, { recursive: true, force: true }); } } -test("setup accepts a packaged SDK build only when it is marked as the pinned commit and Standard revision", () => { +const refused = (run, name, reason) => { + assert.notEqual(run.status, 0, name); + assert.doesNotMatch(run.stdout, /setup complete/, name); + if (reason) assert.match(run.stderr, reason, name); +}; + +test("setup pins the SDK commit, its Standard revision and the digest of its build", () => { assert.match(sdkRev ?? "", /^[0-9a-f]{40}$/); assert.equal(standardRev, "d45c0a292006b2fd2e40d2dbe0cd7ed518ad0f20"); - const accepted = setupWithPackagedSdk({ marker: sdkRev, standardRevision: standardRev }); + assert.match(distSha ?? "", /^[0-9a-f]{64}$/); +}); + +test("the pinned digest is the build of the vendored SDK", { skip: !existsSync(new URL("../vendor/dacs-sdk/dist", import.meta.url)) && "no vendored SDK build" }, () => { + assert.equal(distDigest(new URL("../vendor/dacs-sdk/dist", import.meta.url).pathname), distSha); +}); + +test("setup accepts a packaged SDK build only with the pinned marker, Standard revision and bytes", () => { + const accepted = setup({ marker: sdkRev }); assert.equal(accepted.status, 0, accepted.stderr); - for (const [name, tree] of Object.entries({ - "unmarked prebuilt dist": { standardRevision: standardRev }, - "dist built from another commit": { marker: "0".repeat(40), standardRevision: standardRev }, - "SDK for another Standard revision": { marker: sdkRev, standardRevision: "4bb9e48a1095ab32c06c25b7c0b52018d3ce4091" }, - })) { - const refused = setupWithPackagedSdk(tree); - assert.notEqual(refused.status, 0, name); - assert.doesNotMatch(refused.stdout, /setup complete/, name); + assert.equal(accepted.calls, "", "a packaged build runs no git or npm"); + for (const [name, tree, reason] of [ + ["unmarked prebuilt dist", {}, /not marked/], + ["dist built from another commit", { marker: "0".repeat(40) }, /not marked/], + ["SDK for another Standard revision", { marker: sdkRev, standardRevision: "4bb9e48a1095ab32c06c25b7c0b52018d3ce4091" }, /Standard revision/], + ["marked dist with other bytes", { marker: sdkRev, dist: { "index.js": "export const injected = 1;\n" } }, /not the pinned build/], + ["marked dist with an extra file", { marker: sdkRev, dist: { "index.js": builtIndex, "extra.js": "" } }, /not the pinned build/], + // A symlink would let bytes outside the hashed tree load as part of the SDK. + ["marked dist with a symlink", { marker: sdkRev, link: "linked.js" }, /not a regular file/], + ]) { + refused(setup(tree), name, reason); + } +}); + +test("a source build is accepted only from a clean pinned checkout whose output is the pinned build", () => { + const built = setup({ source: true }); + assert.equal(built.status, 0, built.stderr); + assert.match(built.calls, /npm ci/); + assert.equal(built.marker, sdkRev, "the source build marks its own output"); + assert.match(built.manifest ?? "", /^[0-9a-f]{64} {2}index\.js\n$/, "and records the per-file manifest it pinned"); + + for (const [name, options, reason] of [ + ["checkout at another commit", { head: "1".repeat(40) }, /not a clean checkout/], + ["checkout with local changes", { status: " M src/index.ts\n" }, /not a clean checkout/], + ["SDK for another Standard revision", { standardRevision: "4bb9e48a1095ab32c06c25b7c0b52018d3ce4091" }, /Standard revision/], + ]) { + const run = setup({ source: true, ...options }); + refused(run, name, reason); + assert.doesNotMatch(run.calls, /npm/, `${name}: nothing is built`); + assert.equal(run.marker, undefined, name); } + + // A stale marker never survives a failed or divergent rebuild. + const failed = setup({ source: true, marker: sdkRev, buildFail: true }); + refused(failed, "failed build"); + assert.equal(failed.marker, undefined, "the old marker is cleared before building"); + const divergent = setup({ source: true, marker: sdkRev, buildOutput: "export const other = 1;\n" }); + refused(divergent, "build output that is not the pinned build", /not the pinned build/); + assert.equal(divergent.marker, undefined); }); From 2b4531c55bc55047369d56fe99df29115696ca3c Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:27:11 +0000 Subject: [PATCH 25/37] Build the SDK into an empty dist and fix the digest CLI path check tsc never deletes stale output, so a source build after SDK_REV moves could fail the digest, or pin bytes the commit does not build. Setup now removes vendor/dacs-sdk/dist (marker and manifest included) first. The digest CLI recognised itself as the main module through a URL built from argv, which misreads '#' and '%'. From such an app path it printed nothing and setup reported an empty digest. It now compares file paths. Co-Authored-By: Claude Opus 5.5 --- .../scripts/sdk-dist-digest.mjs | 6 ++-- .../dacs-directory/scripts/setup-sdk.sh | 4 ++- .../dacs-directory/test/setup-sdk.test.mjs | 28 ++++++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs index 6175257..b46b4f5 100644 --- a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs +++ b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs @@ -8,7 +8,8 @@ // usage: node scripts/sdk-dist-digest.mjs [--manifest ] import { createHash } from "node:crypto"; import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; export function distManifest(root) { const files = []; @@ -31,7 +32,8 @@ export function distManifest(root) { export const distDigest = (root) => createHash("sha256").update(distManifest(root)).digest("hex"); -if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { +// Compared as paths: a URL built from argv misreads `#`, `%` and spaces. +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { const [root, flag, out] = process.argv.slice(2); if (!root || (flag !== undefined && (flag !== "--manifest" || !out))) { console.error("usage: node scripts/sdk-dist-digest.mjs [--manifest ]"); diff --git a/reference-implementations/dacs-directory/scripts/setup-sdk.sh b/reference-implementations/dacs-directory/scripts/setup-sdk.sh index a773c7d..4d5c4be 100755 --- a/reference-implementations/dacs-directory/scripts/setup-sdk.sh +++ b/reference-implementations/dacs-directory/scripts/setup-sdk.sh @@ -73,7 +73,9 @@ else exit 1 fi require_standard_revision - rm -f "$SDK_REV_MARKER" "$SDK_MANIFEST" + # tsc never deletes stale output, so build into an empty dist (this also + # clears the marker and manifest). + rm -rf vendor/dacs-sdk/dist (cd vendor/dacs-sdk && npm ci --no-audit --no-fund && npm run build) require_pinned_dist --manifest "$SDK_MANIFEST" printf '%s' "$SDK_REV" > "$SDK_REV_MARKER" diff --git a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs index 3f24f3c..2eceda2 100644 --- a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs +++ b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs @@ -45,7 +45,7 @@ fi /** Run setup in a scratch app root; never installs or fetches anything. */ function setup({ source = false, marker, standardRevision = standardRev, dist = { "index.js": builtIndex }, head = sdkRev, status = "", - buildFail = false, buildOutput = builtIndex, link }) { + buildFail = false, buildOutput = builtIndex, link, stale }) { const root = mkdtempSync(join(tmpdir(), "dacs-setup-sdk-")); try { mkdirSync(join(root, "scripts")); @@ -55,6 +55,10 @@ function setup({ source = false, marker, standardRevision = standardRev, dist = mkdirSync(join(sdk, source ? ".git" : "dist"), { recursive: true }); if (!source) for (const [name, content] of Object.entries(dist)) writeFileSync(join(sdk, "dist", name), content); if (link) symlinkSync(join(root, "outside.js"), join(sdk, "dist", link)); + if (stale) { + mkdirSync(join(sdk, "dist"), { recursive: true }); + writeFileSync(join(sdk, "dist", stale), "export const removedSource = 1;\n"); + } writeFileSync(join(sdk, "package.json"), JSON.stringify({ dacs: { standardRevision } })); if (marker !== undefined) { mkdirSync(join(sdk, "dist"), { recursive: true }); @@ -134,4 +138,26 @@ test("a source build is accepted only from a clean pinned checkout whose output const divergent = setup({ source: true, marker: sdkRev, buildOutput: "export const other = 1;\n" }); refused(divergent, "build output that is not the pinned build", /not the pinned build/); assert.equal(divergent.marker, undefined); + + // Output an earlier SDK_REV left behind is not part of this build. + const rebuilt = setup({ source: true, stale: "removed.js" }); + assert.equal(rebuilt.status, 0, rebuilt.stderr); + assert.match(rebuilt.manifest ?? "", /^[0-9a-f]{64} {2}index\.js\n$/); +}); + +test("the digest CLI prints the digest from an app path with spaces, # or %", () => { + for (const name of ["app 1", "app#1", "app%1", "app%23"]) { + const root = join(mkdtempSync(join(tmpdir(), "dacs-digest-cli-")), name); + try { + mkdirSync(join(root, "scripts"), { recursive: true }); + mkdirSync(join(root, "dist")); + writeFileSync(join(root, "dist/index.js"), builtIndex); + copyFileSync(digestScript, join(root, "scripts/sdk-dist-digest.mjs")); + const run = spawnSync(process.execPath, [join(root, "scripts/sdk-dist-digest.mjs"), join(root, "dist")], { encoding: "utf8" }); + assert.equal(run.status, 0, `${name}: ${run.stderr}`); + assert.equal(run.stdout, `${fixtureSha}\n`, name); + } finally { + rmSync(join(root, ".."), { recursive: true, force: true }); + } + } }); From f388b831691accf188000097016721cc0c5a1a2f Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:27:11 +0000 Subject: [PATCH 26/37] Run the listing-upgrade acceptance check from one shared reader setup The SDK reader configuration that `npm run check:listing-upgrade` runs now lives in src/catalog/listingUpgradeAcceptance.ts. Its test imports that module instead of building its own signed rail table, so the test pins what the script runs. The check still requires `verified` (LR-3, LP-6). With this release's unsigned PA-1 table a pay-bearing replacement resolves `indeterminate` (pa1-definition-unverifiable), so the check fails, and its error now says why. A test helper models a signed snapshot for the verified paths. Co-Authored-By: Claude Opus 5.5 --- .../scripts/check-live-listing-upgrade.ts | 43 +++--------- .../src/catalog/listingUpgradeAcceptance.ts | 52 ++++++++++++++ .../test/sdk-reader-verifiers.test.ts | 69 ++++++++++--------- .../test/signed-rail-snapshot.ts | 14 ++++ 4 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 reference-implementations/dacs-directory/src/catalog/listingUpgradeAcceptance.ts create mode 100644 reference-implementations/dacs-directory/test/signed-rail-snapshot.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 index f6e388d..503aeb0 100644 --- a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -9,11 +9,7 @@ import invalidLiveListings from "../test/fixtures/live-invalid-verification-methods.json"; import { validateListingArtifact } from "../src/sdkListingValidation.js"; import { verifyListingResult } from "../src/catalog/listingVerification.js"; -import { - sdkIdentityPresentationVerifier, - sdkListingSignatureVerifier, -} from "../src/catalog/sdkReaderVerifiers.js"; -import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; +import { listingUpgradeReaderDeps, listingUpgradeRefusal } from "../src/catalog/listingUpgradeAcceptance.js"; type Replacement = { listingId: string; @@ -127,35 +123,14 @@ async function main() { (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: sdkListingSignatureVerifier(), - revocation: { - surfaces: [{ - kind: "catalog", - status: "active", - catalogObservedAt: Number(summary.catalogObservedAt), - }], - readMarker: async (marker) => anchor(marker.locator), - verifyMarkerSignature: () => false, - }, - verifyIdentityPresentation: sdkIdentityPresentationVerifier(), - 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})`); - } + const validation = await validateListingArtifact(replacementRaw, listingUpgradeReaderDeps({ + catalogObservedAt: Number(summary.catalogObservedAt), + readMarker: async (marker) => anchor(marker.locator), + supportedPayloadMethods: replacement.supportedPayloadMethods, + signer: authenticated.value.signer, + })); + const refusal = listingUpgradeRefusal(validation); + if (refusal) throw new Error(`${replacement.listingId} ${refusal}`); process.stdout.write(`ok ${old.listingId}@${old.listingVersion} rejected -> v${replacement.replacementVersion} SDK-verified and discoverable\n`); } diff --git a/reference-implementations/dacs-directory/src/catalog/listingUpgradeAcceptance.ts b/reference-implementations/dacs-directory/src/catalog/listingUpgradeAcceptance.ts new file mode 100644 index 0000000..555fa42 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingUpgradeAcceptance.ts @@ -0,0 +1,52 @@ +/** + * The complete SDK Listing reader that `npm run check:listing-upgrade` runs on + * each replacement Listing. The script and test/sdk-reader-verifiers.test.ts + * both use this module, so the test pins exactly what the check runs. + */ +import type { ListingValidationDeps } from "../sdkListingValidation.js"; +import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "./listingOptions.js"; +import { sdkIdentityPresentationVerifier, sdkListingSignatureVerifier } from "./sdkReaderVerifiers.js"; + +export function listingUpgradeReaderDeps(input: { + catalogObservedAt: number; + readMarker: (marker: { locator: string }) => Promise; + supportedPayloadMethods: readonly string[]; + signer: string; + nowMs?: () => number; +}): ListingValidationDeps { + return { + nowMs: input.nowMs ?? (() => Date.now()), + verifyListingSignature: sdkListingSignatureVerifier(), + revocation: { + surfaces: [{ kind: "catalog", status: "active", catalogObservedAt: input.catalogObservedAt }], + readMarker: input.readMarker, + verifyMarkerSignature: () => false, + }, + verifyIdentityPresentation: sdkIdentityPresentationVerifier(), + loadRailResolution: () => ({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + }), + resolvePayloadVerificationCapability: ({ verificationMethod }) => + input.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 === input.signer, + } as ListingValidationDeps; +} + +/** + * Why a replacement fails acceptance, or null when the reader returned + * `verified` (DACS-1 LR-3, LP-6). With this release's unsigned PA-1 table a + * pay-bearing replacement resolves `indeterminate`, so the check refuses it, + * as the publisher does. + */ +export function listingUpgradeRefusal(validation: { disposition: string; reason?: string }): string | null { + if (validation.disposition === "verified") return null; + const result = `complete SDK reader returned ${validation.disposition} (${validation.reason ?? "no reason"})`; + return validation.reason === "pa1-definition-unverifiable" + ? `${result}: this release's PA-1 rail table is unsigned, so no pay-bearing replacement is LP-6 verified until a signed snapshot ships` + : result; +} diff --git a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts index 9b2664e..1c95369 100644 --- a/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts +++ b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts @@ -3,12 +3,13 @@ import test from "node:test"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { PUBLISHABLE_RAIL_OPTIONS } from "../src/catalog/listingOptions.js"; +import { listingUpgradeReaderDeps, listingUpgradeRefusal } from "../src/catalog/listingUpgradeAcceptance.js"; import { sdkIdentityPresentationVerifier, sdkListingSignatureVerifier, } from "../src/catalog/sdkReaderVerifiers.js"; -import { validateListingArtifact, type ListingValidationDeps } from "../src/sdkListingValidation.js"; +import { validateListingArtifact } from "../src/sdkListingValidation.js"; +import { modelSignedRailSnapshot } from "./signed-rail-snapshot.js"; const seed = Uint8Array.from(Buffer.alloc(32, 31)); const privateKey = privateKeyFromSeed(seed); @@ -58,30 +59,26 @@ const listing = { }; const hexValue = Buffer.from(listing.signature.value, "base64url").toString("hex"); -function readerDeps(): ListingValidationDeps { - return { - nowMs: () => 2, - verifyListingSignature: sdkListingSignatureVerifier(), - revocation: { - surfaces: [{ kind: "catalog", status: "active", catalogObservedAt: 2 }], - readMarker: async () => null, - verifyMarkerSignature: () => false, - }, - verifyIdentityPresentation: sdkIdentityPresentationVerifier(), - loadRailResolution: () => ({ - trustPhase: "PA-1", - trustPolicyAcceptsPA1: true, - registry: { state: "not-used", entries: [], definitions: [] }, - // A reader whose trust policy holds a verified, signed PA-1 snapshot (LRR-6). - inCodeDefinitions: PUBLISHABLE_RAIL_OPTIONS.map((rail) => ({ - railId: rail.railId, railVersion: rail.railVersion, phaseHandler: rail.phaseKind, - governanceAnchoring: "in-code", signatureValid: true, - })), - }), - resolvePayloadVerificationCapability: () => ({ disposition: "unsupported", reason: "not configured" }), - verifySellerControl: ({ bundle, signer }) => signer === bundle.presentedBy, - } as ListingValidationDeps; -} +/** Exactly what `npm run check:listing-upgrade` passes to the SDK reader. */ +const readerDeps = () => listingUpgradeReaderDeps({ + nowMs: () => 2, + catalogObservedAt: 2, + readMarker: async () => null, + supportedPayloadMethods: [], + signer: claim, +}); + +test("the acceptance check refuses a valid replacement while the PA-1 table is unsigned, as the publisher does", async () => { + const result = await validateListingArtifact(listing, readerDeps()) as { disposition: string; step?: number; reason?: string }; + assert.equal(result.disposition, "indeterminate"); + assert.equal(result.step, 8); + assert.equal(result.reason, "pa1-definition-unverifiable"); + assert.match(listingUpgradeRefusal(result) ?? "accepted", /unsigned, so no pay-bearing replacement is LP-6 verified/); + assert.equal(listingUpgradeRefusal({ disposition: "verified" }), null); + for (const disposition of ["rejected", "revoked", "indeterminate"]) { + assert.match(listingUpgradeRefusal({ disposition, reason: "other" }) ?? "accepted", new RegExp(`returned ${disposition} \\(other\\)$`)); + } +}); test("acceptance verifiers verify SIG-6 values against the signer key, never by self-comparison", async () => { const verifyListingSignature = sdkListingSignatureVerifier(); @@ -101,11 +98,21 @@ test("acceptance verifiers verify SIG-6 values against the signer key, never by assert.equal(await verifyIdentityPresentation({ bundle, signedBytes }), false); }); -test("the SDK ordered reader with the acceptance verifiers accepts SIG-6 and rejects the same bytes as hex", async () => { - assert.equal((await validateListingArtifact(listing, readerDeps())).disposition, "verified"); - const hexListing = { ...listing, signature: { ...listing.signature, value: hexValue } }; - const rejected = await validateListingArtifact(hexListing, readerDeps()); - assert.equal(rejected.disposition, "rejected"); +test("with a signed PA-1 snapshot the acceptance reader verifies SIG-6 and rejects the same bytes as hex", async () => { + const restore = modelSignedRailSnapshot(); + try { + assert.equal((await validateListingArtifact(listing, readerDeps())).disposition, "verified"); + const hexListing = { ...listing, signature: { ...listing.signature, value: hexValue } }; + const rejected = await validateListingArtifact(hexListing, readerDeps()); + assert.equal(rejected.disposition, "rejected"); + // Seller control is bound to the authenticated signer the script passes. + const otherSigner = listingUpgradeReaderDeps({ + nowMs: () => 2, catalogObservedAt: 2, readMarker: async () => null, supportedPayloadMethods: [], signer: otherClaim, + }); + assert.notEqual((await validateListingArtifact(listing, otherSigner)).disposition, "verified"); + } finally { + restore(); + } }); test("the identity presentation verifier accepts only per-claim presentations", async () => { diff --git a/reference-implementations/dacs-directory/test/signed-rail-snapshot.ts b/reference-implementations/dacs-directory/test/signed-rail-snapshot.ts new file mode 100644 index 0000000..47d652c --- /dev/null +++ b/reference-implementations/dacs-directory/test/signed-rail-snapshot.ts @@ -0,0 +1,14 @@ +import { PUBLISHER_IN_CODE_RAIL_DEFINITIONS } from "../src/catalog/listingOptions.js"; + +/** + * Model a release whose PA-1 table is a verified, signed snapshot (LRR-6), so + * a test can reach the publication steps behind the LP-6 gate. The shipped + * table is unsigned and the publisher refuses; call the returned function to + * restore it. + */ +export function modelSignedRailSnapshot(): () => void { + for (const definition of PUBLISHER_IN_CODE_RAIL_DEFINITIONS) definition.signatureValid = true; + return () => { + for (const definition of PUBLISHER_IN_CODE_RAIL_DEFINITIONS) definition.signatureValid = false; + }; +} From 25f4c5865ef3122c3934c444017b60767d6dc89c Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:27:11 +0000 Subject: [PATCH 27/37] Refuse unverified listing publication and pin the node everywhere DACS-1 needs LP-6 `verified` rail authority and an LP-1 verified `finalized` receipt before a pay-bearing listing is published. The Directory has neither. - build-listing resolves rail authority before the identity step and publishes only on `verified`. The PA-1 table is unsigned, so /register now refuses every listing with 409 before the seller signs anything. - confirm-listing issues no AnchorReceipt. It returns the pinned node's report as an unauthenticated nodeObservation with confirmed:false and state finality-unverified, so the browser never registers on it. On Demos an included write is also final, and consensus is not verified. - build-listing checks the node's build and chain before returning any transaction, so an unpinned or dirty node is refused before broadcast. - reindexAll runs the same check before a pass. A node outside the pin fails the pass without changing the catalog or scan state. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 33 ++-- .../app/api/dacs/build-listing/route.ts | 54 +++--- .../app/api/dacs/confirm-listing/route.ts | 27 +-- .../api/dacs/prepare-registration/route.ts | 5 +- .../src/catalog/listingFinality.ts | 166 ++++++++---------- .../src/catalog/listingOptions.ts | 13 +- .../src/catalog/nodeDeployment.ts | 16 +- .../dacs-directory/src/catalog/reindexCore.ts | 11 ++ .../dacs-directory/test/build-listing.test.ts | 106 ++++++++++- .../test/confirm-listing.test.ts | 107 ++++++----- .../test/listing-admission.test.ts | 59 +------ .../test/malformed-artifacts.test.ts | 10 +- .../dacs-directory/test/rail-registry.test.ts | 60 +++++-- .../test/reindex-chain-reset.test.ts | 2 +- .../dacs-directory/test/scan-fairness.test.ts | 4 +- .../test/scan-robustness.test.ts | 24 ++- 16 files changed, 409 insertions(+), 288 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 16342b5..f8e5d11 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -24,7 +24,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` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`). LRR-6 accepts only a signed snapshot, and the pinned Standard publishes no signed v0.1 snapshot, so the table is passed as unsigned and the result is `indeterminate` (`pa1-definition-unverifiable`). The publisher accepts only that result, in which every other LRR check passed, surfaces it as `railResolution`, and does not claim LP-6 `verified`. Any `rejected` or other indeterminate result refuses publication, 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 an `AnchorReceipt` in the pinned SDK's portable Demos form with state `included`, shown as "included (finality not verified)" (the write is read back by hash, and the block is one the configured Demos node reports as confirmed; the Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum, so it never labels the write final), and independently verifies native address, owner, program name, tuple, identity, signature and content hash before the browser submits its catalog registration. The receipt gates only this cooperative flow: `POST /api/dacs/register` accepts pointer sets without one, and the indexer re-reads and re-authenticates every anchor on each pass | +| Seller publication | DACS-1 §6.3.4 | **`/register` does not publish pay-bearing listings at this pin.** DACS-1 requires LP-6 `verified` rail authority and an LP-1 verified `finalized` AnchorReceipt before a pay-bearing listing is published, and the Directory can establish neither. `/register` emits DACS-4 §9.4.2 registry rail IDs (`demos-native:DEM`, `x402:default`, `ap2:stripe-paymentintents`) and runs the SDK rail resolver over this release's disclosed PA-1 in-code table (`pa1-in-code`). LRR-6 accepts only a signed snapshot, and the pinned Standard publishes no signed v0.1 snapshot, so the table is unsigned and every rail resolves `indeterminate` (`pa1-definition-unverifiable`). `build-listing` therefore refuses with 409 before the seller signs anything; it publishes only on `verified`. Every `/register` listing has exactly one rail, so `/register` refuses every listing until a signed PA-1 snapshot or an authenticated PA-2/PA-3 registry exists. Behind that gate, `/register` refuses attested-payload methods whose seller-runtime production capability it cannot prove. It shows the exact server-built unsigned Listing before signature and checks that the configured node reports the pinned build and chain before it returns any transaction. It creates the StorageProgram with the current SDK name/next-nonce/empty-salt mapping and persists non-secret recovery coordinates before broadcast. After broadcast, `confirm-listing` independently verifies native address, owner, program name, tuple, identity, signature and content hash. It reads the write back by hash, in a block the pinned node reports as confirmed. It returns that report only as an unauthenticated `nodeObservation` with `confirmed:false` and state `finality-unverified`, never as an `AnchorReceipt`. The Directory checks the node's validator-signature shape but, like the pinned SDK adapter, does not verify the validator pool, signatures or quorum, and on Demos an `included` write is also `finalized`. The browser therefore stops there and does not register the listing. `POST /api/dacs/register` still accepts pointer sets from any client, and the indexer re-reads and re-authenticates every anchor on each pass | | 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 | @@ -122,8 +122,8 @@ continue. Moving `SDK_REV` therefore also means rebuilding once and updating `SD | Variable | Required | Purpose | |---|---|---| | `DEMOS_RPC` | No | Demos RPC base URL; defaults to the public testnet endpoint | -| `DEMOS_CHAIN_IDENTITY` | No | Chain the listing receipts are pinned to (block one's `previousHash`); defaults to `83082fbb…7dd4`, the chain node2 serves. A node on another chain issues no receipt | -| `DEMOS_NODE_BUILDS` | No | Comma-separated node builds that may issue listing receipts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. node2 currently reports a dirty build, so `/register` refuses its receipts until it is rebuilt clean or the operator pins that exact build | +| `DEMOS_CHAIN_IDENTITY` | No | Chain the Directory's node must serve (block one's `previousHash`); defaults to `83082fbb…7dd4`, the chain node2 serves. On another chain, reindexing fails without changing the catalog, `build-listing` returns no transaction and `confirm-listing` reports nothing | +| `DEMOS_NODE_BUILDS` | No | Comma-separated node builds the Directory accepts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. The node reports its own build, so this detects drift and does not authenticate the node. node2 currently reports a dirty build, so **`npm run index` and `/api/dacs/reindex` fail without changing the catalog** until node2 is rebuilt clean or the operator pins that exact build | | `NEXT_PUBLIC_DEMOS_RPC` | No | Credential-free public HTTPS Demos RPC advertised to machine clients; defaults to the public testnet endpoint and deliberately does not inherit `DEMOS_RPC` | | `DACS_ADMIN_TOKEN` | Production | Bearer token for the operational reindex endpoints | | `DACS_SDK_GITHUB_TOKEN` | GitHub deploy | Fine-grained, read-only token for cloning the pinned private SDK during the build; not needed after the SDK is published | @@ -216,6 +216,15 @@ readiness still unassessed. It never contacts a gateway and never writes, reinde 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. +The check requires the reader's `verified` result (DACS-1 LR-3, LP-6), and its rail +authority comes from this release's PA-1 table. That table is unsigned, so the SDK reader +answers `indeterminate` (`pa1-definition-unverifiable`) for every pay-bearing replacement. +**The check therefore cannot pass at this pin**, and it fails with that reason, just as +`/register` refuses to publish. It can pass once a signed PA-1 snapshot or an authenticated +PA-2/PA-3 registry ships. The script's reader configuration lives in +`src/catalog/listingUpgradeAcceptance.ts`, which `test/sdk-reader-verifiers.test.ts` also +imports. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, @@ -275,16 +284,14 @@ base64url-patched Buffer). 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 in a block the configured node reports as confirmed, - a portable `AnchorReceipt` with state `included` ("included (finality not verified)"), and - independent native readback. The Directory never labels a write final or settled on - inclusion alone: verifying Demos finality (validator signatures, eligible pool, 2/3+1 - quorum) needs SDK support, and the pinned SDK's Demos decoder reads only `finalized` - receipts, so these receipts are not SDK finality evidence. That wait protects this browser flow - only: registrations do not carry the receipt, `POST /api/dacs/register` accepts pointer - sets from any client, and catalog admission rests on the indexer re-reading and - re-authenticating each anchor rather than on transaction finality. A reload re-verifies that same anchor + native/write coordinates and unsigned registration. After broadcast, registration would + need a verified `finalized` AnchorReceipt (DACS-1 LP-1). Verifying Demos finality + (validator signatures, eligible pool, 2/3+1 quorum) needs SDK support, so + `confirm-listing` returns only an unauthenticated `nodeObservation` and the browser does + not register. Today the rail gate above stops `/register` before any of this. + `POST /api/dacs/register` accepts pointer sets from any client, and catalog admission + rests on the indexer re-reading and re-authenticating each anchor from the pinned node + deployment rather than on transaction finality. 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 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 6563ddb..e93b10f 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 @@ -36,6 +36,7 @@ import { loadCatalog, loadRegistrations } from "@/src/catalog/store"; import { registrationMessage } from "@/src/catalog/registrationSig"; import { safePublicEndpoint } from "@/src/catalog/publicEndpoint"; import { verifyListing } from "@/src/catalog/listingVerification"; +import { pinnedNodeDeployment } from "@/src/catalog/listingFinality"; import { negotiationPhaseForPricing, publishableRail, @@ -163,6 +164,27 @@ export async function POST(req: NextRequest) { !decimal.test(minTotal) || !Number.isFinite(Number(minTotal)) || Number(minTotal) <= 0 )) return NextResponse.json({ error: "metered minimum must be a positive canonical amount" }, { status: 400 }); + // LP-6: resolve rail authority before the seller signs anything. + const payPhase = { kind: selectedRail.phaseKind, parameters: { rail: selectedRail.railId } }; + const acceptedRails = [{ railId: selectedRail.railId }]; + const railResolution = resolveListingRails({ + trustPhase: "PA-1", + trustPolicyAcceptsPA1: true, + payPhases: [{ kind: payPhase.kind, rail: payPhase.parameters.rail }], + acceptedRails, + registry: { state: "not-used", entries: [], definitions: [] }, + inCodeDefinitions: PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + }); + if (!railAuthorityPermitsPublication(railResolution)) { + return NextResponse.json( + { + error: `listing rail authority is ${railResolution.disposition} (${railResolution.reason}), not verified (DACS-1 LP-6); publication refused`, + railResolution, + }, + { status: 409 }, + ); + } + // A current Listing embeds a separately signed IdentityBundle. The first // request returns that preimage; the second includes the wallet signature // and receives the exact Listing preimage. Only a third request carrying the @@ -237,7 +259,7 @@ export async function POST(req: NextRequest) { const pipeline = [ negotiationStep, { kind: "commit-agreement" }, - { kind: selectedRail.phaseKind, parameters: { rail: selectedRail.railId } }, + payPhase, { kind: delivery[0] }, ]; const listing = { @@ -260,29 +282,10 @@ export async function POST(req: NextRequest) { buyerRequirement: { requirementVersion: "1", required: [], preferredPresentation: "any" }, pipeline, pricing, - acceptedRails: [{ railId: selectedRail.railId }], + acceptedRails, 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 (!railAuthorityPermitsPublication(railResolution)) { - 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", @@ -396,6 +399,15 @@ export async function POST(req: NextRequest) { payloadCapability, }); } + // Broadcasting is the seller's irreversible step, so refuse a node outside + // the pin here rather than after the write (confirm-listing checks again). + const deployment = await pinnedNodeDeployment().catch(() => null); + if (!deployment) { + return NextResponse.json({ error: "could not read the Demos node's build and chain; no transaction was prepared" }, { status: 503 }); + } + if (!deployment.ok) { + return NextResponse.json({ error: `${deployment.reason}; no transaction was prepared` }, { status: 409 }); + } const nonce = await accountNonce(hex); const txNonce = nonce + 1; anchorAddress = deriveStorageAddress(owner, programName, txNonce, LIVE_STORAGE_SALT); 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 e1cd1d6..d28a530 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,18 +2,18 @@ * POST /api/dacs/confirm-listing — independently read and verify a listing * after the wallet broadcasts its StorageProgram transaction. * - * Visibility alone is insufficient: the canonical transaction and the block - * the configured node reports as confirmed first produce a portable - * `included` AnchorReceipt. Demos finality is not verified (validator - * signatures and quorum need SDK support), so nothing here is labelled final. 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. + * The native address, owner, producer-held program name, listing tuple, + * content hash, seller, identity presentation and listing signature must all + * bind, and the pinned node must report the canonical transaction in a + * confirmed block. That report is returned as an unauthenticated + * `nodeObservation`, never as an AnchorReceipt: Demos consensus is not + * verified, so DACS-1 LP-1 is not met and the response never confirms the + * publication for registration. */ import { NextRequest, NextResponse } from "next/server"; import { listingAddress } from "@kynesyslabs/dacs/canonical"; import { readAnchorRecord } from "@/src/catalog/chain"; -import { includedListingAnchorReceipt } from "@/src/catalog/listingFinality"; +import { observeListingAnchorInclusion } from "@/src/catalog/listingFinality"; import { verifyListing } from "@/src/catalog/listingVerification"; import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security"; @@ -83,7 +83,7 @@ export async function POST(req: NextRequest) { body.listingId, Number(body.listingVersion), ); - const inclusion = await includedListingAnchorReceipt({ + const inclusion = await observeListingAnchorInclusion({ anchorAddress: body.anchorAddress, logicalAddress, programName: body.programName, @@ -107,14 +107,15 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ - confirmed: true, - state: "included-and-verified", - finality: inclusion.finality, + confirmed: false, + state: "finality-unverified", + error: "the configured Demos node reports this write as included, but the Directory cannot verify Demos finality (DACS-1 LP-1), so it does not register the listing", + authenticated: inclusion.authenticated, anchorAddress: body.anchorAddress, contentHash: verified.contentHash, sellerClaim: verified.sellerClaim, listingId: body.listingId, listingVersion: body.listingVersion, - anchorReceipt: inclusion.receipt, + nodeObservation: inclusion.observation, }); } 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 1145604..68faaff 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,8 +1,9 @@ /** * POST /api/dacs/prepare-registration — issue a fresh, content-bound owner * signing message for a registration pointer set. It only parses and signs: - * the `/register` page calls it after confirm-listing has checked finality, - * and the indexer re-authenticates every anchor before catalog admission. + * the `/register` page calls it only after confirm-listing confirms the + * publication, and the indexer re-authenticates every anchor before catalog + * admission. * * 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/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts index e99e969..6ac10d5 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -1,14 +1,8 @@ import { canonicalize, contentHash, sha256Hex } from "@kynesyslabs/dacs/canonical"; -import { - assertDemosWriteEvidence, - demosSignedTransactionProofHash, - demosWriteEvidenceToAnchorReceipt, - type DemosWriteEvidence, -} from "../sdkDemosWriteEvidence.js"; -import { isAnchorReceipt, type AnchorReceipt } from "@kynesyslabs/dacs/artifacts"; +import { demosSignedTransactionProofHash } from "../sdkDemosWriteEvidence.js"; import { readAnchorRecord } from "./chain.js"; -import { nodeBuildRejection, pinnedChainIdentity } from "./nodeDeployment.js"; +import { nodeBuildRejection, pinnedChainIdentity, reportedNodeBuild } from "./nodeDeployment.js"; import { storageWriteCandidate } from "./scan.js"; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); @@ -134,56 +128,60 @@ async function findCanonicalWrite( } /** - * Inclusion is all the Directory can establish: it does not verify Demos - * consensus (validator signatures, eligible pool, quorum), so it never labels - * a write final. Verified Demos finality needs SDK support. + * The configured node's deployment against this release's pin: its reported + * build, then the chain it serves. The node reports both itself, so this + * detects drift; it does not authenticate the node. Throws when the node + * cannot be read. */ -export type ListingAnchorInclusionResult = - | { status: "pending"; reason: string } - | { status: "rejected"; reason: string } - | { status: "included"; finality: "not-verified"; receipt: AnchorReceipt }; - -export interface ExpectedListingAnchorWrite { - anchorAddress: string; - logicalAddress: string; - listingContentHash: string; - sellerClaim: string; - transactionRef: string; +export async function pinnedNodeDeployment(): Promise< + | { ok: true; build: string; chainIdentity: string } + | { ok: false; reason: string } +> { + const networkInfo = await nodeCall("getNetworkInfo", {}); + const buildRejection = nodeBuildRejection(networkInfo); + if (buildRejection) return { ok: false, reason: buildRejection }; + const servedChain = await chainIdentity(); + if (servedChain !== pinnedChainIdentity()) { + return { ok: false, reason: `Demos node serves chain ${servedChain}, not the pinned chain` }; + } + return { ok: true, build: reportedNodeBuild(networkInfo) ?? "", chainIdentity: servedChain }; } /** - * Registration admits a Listing only on a receipt that passes the SDK's CORE - * §5.1 AnchorReceipt gate, records an established included write, and binds - * this exact listing write. A `finalized` receipt is refused: this flow cannot - * verify Demos finality, so such a claim would be unverified. Returns the - * rejection reason, or null to admit. + * What the configured, pinned node reports about one listing write. None of it + * is authenticated: Demos consensus (validator signatures, eligible pool, + * quorum) is not verified, and on Demos a valid `included` write is also + * `finalized` (DEMOS-MAPPING SR-2). So this is never issued as a CORE §5.1 + * AnchorReceipt and never satisfies DACS-1 LP-1. */ -export function listingAnchorReceiptRejection( - receipt: unknown, - expected: ExpectedListingAnchorWrite, -): string | null { - if (!isAnchorReceipt(receipt)) return "anchor receipt is not a CORE §5.1 AnchorReceipt"; - if (receipt.state !== "included" || receipt.observationDisposition !== "established") { - return "anchor receipt does not establish an included write"; - } - if ( - receipt.logicalAddress !== expected.logicalAddress || - receipt.nativeAddress !== expected.anchorAddress || - receipt.contentHash !== expected.listingContentHash || - receipt.writer !== expected.sellerClaim || - receipt.transactionRef.value !== expected.transactionRef - ) return "anchor receipt does not bind this listing write"; - return null; +export interface ListingAnchorNodeObservation { + chainIdentity: string; + nodeBuild: string; + logicalAddress: string; + nativeAddress: string; + contentHash: string; + writer: string; + transactionRef: string; + signedTransactionHash: string; + blockNumber: number; + blockHash: string; + blockTimestamp: number; + validationDataHash: string; + nativeRead: { owner: string; programName: string; valueHash: string; metadataHash: string }; } +export type ListingAnchorInclusionResult = + | { status: "pending"; reason: string } + | { status: "rejected"; reason: string } + | { status: "included"; authenticated: false; observation: ListingAnchorNodeObservation }; + /** - * Establish an `included` Demos AnchorReceipt from the canonical transaction, - * the block the configured node reports as confirmed, and exact native - * readback. Read visibility alone never returns `included`, and nothing here - * returns `finalized`: consensus signatures are not verified (see - * validatorSignatureShape). + * Observe a listing write through the canonical transaction, the block the + * configured node reports as confirmed, and exact native readback. Read + * visibility alone never returns `included`, and the result is an + * unauthenticated node observation, not a receipt (see validatorSignatureShape). */ -export async function includedListingAnchorReceipt(input: { +export async function observeListingAnchorInclusion(input: { anchorAddress: string; logicalAddress: string; programName: string; @@ -226,45 +224,15 @@ export async function includedListingAnchorReceipt(input: { !validatorSignatureShape(block.validation_data) ) return { status: "pending", reason: "canonical block is not confirmed by the configured node" }; - // Only the pinned deployment's report becomes a portable receipt. - const buildRejection = nodeBuildRejection(await nodeCall("getNetworkInfo", {})); - if (buildRejection) return { status: "rejected", reason: buildRejection }; - const servedChain = await chainIdentity(); - if (servedChain !== pinnedChainIdentity()) { - return { status: "rejected", reason: `Demos node serves chain ${servedChain}, not the pinned chain` }; - } + // Only the pinned deployment's report is passed on. + const deployment = await pinnedNodeDeployment(); + if (!deployment.ok) return { status: "rejected", reason: deployment.reason }; 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: servedChain, - 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) || @@ -272,22 +240,30 @@ export async function includedListingAnchorReceipt(input: { metadata.contentHash !== input.listingContentHash || metadata.envelopeHash !== nativeValueHash ) return { status: "rejected", reason: "native listing metadata does not bind the signed artifact" }; - assertDemosWriteEvidence(evidence); - // The SDK's portable form always states `finalized`; without consensus - // verification the Directory can establish only inclusion (CORE §5.1). - const receipt: AnchorReceipt = { - ...demosWriteEvidenceToAnchorReceipt({ + return { + status: "included", + authenticated: false, + observation: { + chainIdentity: deployment.chainIdentity, + nodeBuild: deployment.build, logicalAddress: input.logicalAddress, + nativeAddress: input.anchorAddress, contentHash: input.listingContentHash, writer: input.sellerClaim, - evidence, - }), - state: "included", + transactionRef: txRef, + signedTransactionHash: demosSignedTransactionProofHash(tx), + blockNumber, + blockHash: block.hash, + blockTimestamp, + validationDataHash: sha256Hex(canonicalize(block.validation_data)), + nativeRead: { + owner, + programName: input.programName, + valueHash: nativeValueHash, + metadataHash: sha256Hex(canonicalize(metadata)), + }, + }, }; - const rejection = listingAnchorReceiptRejection(receipt, { ...input, transactionRef: txRef }); - return rejection - ? { status: "rejected", reason: rejection } - : { status: "included", finality: "not-verified", receipt }; } catch (error) { return { status: "pending", @@ -301,7 +277,7 @@ export async function includedListingAnchorReceipt(input: { * key to Ed25519 block signature. This refuses placeholder or arbitrary * objects, but it is a shape check only. The signatures, the eligible * validator pool and the 2/3+1 quorum are not verified here, nor by the - * pinned SDK's own Demos adapter, which is why receipts state `included`. + * pinned SDK's own Demos adapter, which is why no receipt is issued. */ function validatorSignatureShape(value: unknown): boolean { const signatures = record(record(value)?.signatures); diff --git a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts index 6997b52..b3bee7a 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts @@ -35,14 +35,13 @@ export const PUBLISHER_IN_CODE_RAIL_DEFINITIONS = PUBLISHABLE_RAIL_OPTIONS.map(( })); /** - * Publication needs LP-6 `verified`, or the one `indeterminate` result in - * which every LRR check passed except the unsigned PA-1 table. The latter is - * surfaced as such and never described as `verified`; any `rejected` result, - * or another indeterminate reason, refuses publication. + * A pay-bearing listing is published only on DACS-1 LP-6 `verified`. With the + * unsigned table above every rail resolves `indeterminate`, so the browser + * publisher refuses until a signed PA-1 snapshot or an authenticated PA-2/PA-3 + * registry exists. */ -export function railAuthorityPermitsPublication(result: { disposition: string; reason?: string }): boolean { - return result.disposition === "verified" || - (result.disposition === "indeterminate" && result.reason === "pa1-definition-unverifiable"); +export function railAuthorityPermitsPublication(result: { disposition: string }): boolean { + return result.disposition === "verified"; } export const PUBLISHABLE_PRICING_KINDS = ["fixed", "negotiable", "auction", "metered"] as const; diff --git a/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts b/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts index 1b63e90..1543074 100644 --- a/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts +++ b/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts @@ -32,13 +32,17 @@ const record = (value: unknown): Record | null => ? value as Record : null; -/** Return why the node's reported build is not pinned, or null when it is. */ -export function nodeBuildRejection(networkInfo: unknown, builds = pinnedNodeBuilds()): string | null { +/** The build getNetworkInfo reports, in pin form, or null when it reports none. */ +export function reportedNodeBuild(networkInfo: unknown): string | null { const version = record(record(networkInfo)?.nodeVersion); const commit = typeof version?.commit === "string" ? version.commit : ""; - if (!/^[0-9a-f]{40}$/.test(commit) || typeof version?.dirty !== "boolean") { - return "Demos node reports no build identity"; - } - const build = version.dirty ? `${commit}+dirty@${String(version.builtAt ?? "")}` : commit; + if (!/^[0-9a-f]{40}$/.test(commit) || typeof version?.dirty !== "boolean") return null; + return version.dirty ? `${commit}+dirty@${String(version.builtAt ?? "")}` : commit; +} + +/** Return why the node's reported build is not pinned, or null when it is. */ +export function nodeBuildRejection(networkInfo: unknown, builds = pinnedNodeBuilds()): string | null { + const build = reportedNodeBuild(networkInfo); + if (build === null) return "Demos node reports no build identity"; return builds.includes(build) ? null : `Demos node build ${build} is not pinned`; } diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 88e9bf9..cc2e798 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -15,6 +15,7 @@ import { scanConsensusAnchorBackfill, } from "./scan"; import { chainResetRequired, chainResetThreshold } from "./chainContinuity"; +import { pinnedNodeDeployment } from "./listingFinality"; import { crawlDomains } from "./wellknown"; import { upsertCounterpartyEvidenceSeller } from "./counterpartyEvidence"; import { refreshReachabilityHints } from "./reachability"; @@ -55,6 +56,11 @@ export interface ReindexOptions { */ resolveIdentities?: ResolveIdentities; resolveRecipe?: ResolveRecipe; + /** + * Deployment preflight injected by tests. Production checks the configured + * node's reported build and chain against the pin (pinnedNodeDeployment). + */ + nodeDeployment?: () => Promise<{ ok: true } | { ok: false; reason: string }>; } export async function reindexAll(opts: ReindexOptions = {}): Promise { @@ -89,6 +95,11 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise rmSync(dataDir, { recursive: true, force: true })); +// The shipped PA-1 table is unsigned, so the route refuses every rail before +// these steps (rail-registry.test.ts); here a signed snapshot is modelled. +const restoreRailSnapshot = modelSignedRailSnapshot(); +test.after(() => { + restoreRailSnapshot(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +/** node2's getNetworkInfo `nodeVersion` for the pinned commit. */ +const cleanBuild = { commit: PINNED_DEMOS_NODE_BUILDS[0], dirty: false, builtAt: "2026-10-14T16:45:24Z" }; +/** + * Answer the deployment preflight (getNetworkInfo, then block zero's error and + * block one's predecessor) as the pinned build and chain; null for other calls. + */ +function deploymentReply( + init: RequestInit | undefined, + nodeVersion: Record = cleanBuild, + chain = PINNED_DEMOS_CHAIN_IDENTITY, +): Response | null { + if (init?.method !== "POST") return null; + const call = (JSON.parse(String(init.body)) as { params?: Array<{ message?: string; data?: { blockNumber?: number } }> }).params?.[0]; + if (call?.message === "getNetworkInfo") return Response.json({ result: 200, response: { nodeVersion } }); + if (call?.message === "getBlockByNumber" && call.data?.blockNumber === 0) return Response.json({ result: 400, response: "error" }); + if (call?.message === "getBlockByNumber" && call.data?.blockNumber === 1) { + return Response.json({ + result: 200, + response: { id: 2, number: 1, status: "confirmed", hash: "12".repeat(32), content: { previousHash: chain, timestamp: 1 } }, + }); + } + return null; +} const seed = Uint8Array.from(Buffer.alloc(32, 12)); const privateKey = privateKeyFromSeed(seed); @@ -65,6 +97,8 @@ test("publisher uses PA-1 rail resolution and signs the exact staged Listing bef const originalFetch = globalThis.fetch; globalThis.fetch = async (_input, init) => { + const deployment = deploymentReply(init); + if (deployment) return deployment; if (init?.method === "POST") { const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }; if (rpc.params?.[0]?.message === "searchStoragePrograms") { @@ -101,10 +135,8 @@ test("publisher uses PA-1 rail resolution and signs the exact staged Listing bef }; assert.equal(preview.publicationReady, false); assert.equal(preview.tx, null, "an unsigned preview must not contain a broadcastable transaction"); - // LRR-6 accepts only a signed PA-1 snapshot; this release's table is unsigned, so - // the publisher surfaces that result and never claims LP-6 `verified`. - assert.equal(preview.railResolution.disposition, "indeterminate"); - assert.equal(preview.railResolution.reason, "pa1-definition-unverifiable"); + // LP-6: publication proceeds only on `verified`, here from the modelled signed snapshot. + 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, { @@ -186,6 +218,8 @@ test("publisher steps past an owner-held version whose artifact no longer authen let occupied: { address: string; name: string; data: Record } | undefined; const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { + const deployment = deploymentReply(init); + if (deployment) return deployment; if (init?.method === "POST") { const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string; data?: { query?: string } }> }; if (rpc.params?.[0]?.message === "searchStoragePrograms") { @@ -249,6 +283,8 @@ test("publisher recovers an immutable owner-bound listing instead of creating a const originalFetch = globalThis.fetch; globalThis.fetch = async (_input, init) => { + const deployment = deploymentReply(init); + if (deployment) return deployment; if (init?.method === "POST") { const rpc = JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }; return rpc.params?.[0]?.message === "searchStoragePrograms" @@ -313,6 +349,66 @@ test("publisher recovers an immutable owner-bound listing instead of creating a } }); +test("publisher prepares no transaction unless the node is the pinned deployment", async () => { + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const hexSig = (message: string) => + Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("hex"); + const finalInput = { + ...base, + serviceId: "deployment-pin", + identityPresentedAt: identity.identityPresentedAt, + identitySignature: hexSig(identity.identityMessage), + }; + const dirty = { ...cleanBuild, dirty: true }; + let node: { nodeVersion: Record; chain: string; down?: boolean } = { nodeVersion: cleanBuild, chain: PINNED_DEMOS_CHAIN_IDENTITY }; + let calls: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + if (init?.method !== "POST") return new Response(null, { status: 404 }); + const message = (JSON.parse(String(init.body)) as { params?: Array<{ message?: string }> }).params?.[0]?.message ?? ""; + calls.push(message); + if (node.down && message === "getNetworkInfo") return new Response("unavailable", { status: 503 }); + const deployment = deploymentReply(init, node.nodeVersion, node.chain); + if (deployment) return deployment; + return Response.json({ result: 200, response: message === "searchStoragePrograms" ? [] : 0 }); + }; + const listingSignature = async () => { + const preview = await (await POST(request(finalInput))).json() as { message: string; tx: unknown }; + assert.equal(preview.tx, null); + return hexSig(preview.message); + }; + try { + for (const [name, state, status, error] of [ + // node2 today: the pinned commit with uncommitted changes. + ["dirty build", { nodeVersion: dirty, chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, /\+dirty@2026-10-14T16:45:24Z is not pinned; no transaction was prepared/], + ["another commit", { nodeVersion: { ...cleanBuild, commit: "f".repeat(40) }, chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, /is not pinned/], + ["no build identity", { nodeVersion: {}, chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, /reports no build identity/], + ["another chain", { nodeVersion: cleanBuild, chain: "aa".repeat(32) }, 409, /not the pinned chain/], + ["unreadable node", { nodeVersion: cleanBuild, chain: PINNED_DEMOS_CHAIN_IDENTITY, down: true }, 503, /could not read the Demos node/], + ] as const) { + node = state; + const signature = await listingSignature(); + calls = []; + const response = await POST(request({ ...finalInput, listingSignature: signature })); + assert.equal(response.status, status, name); + const body = await response.json() as { error?: string; tx?: unknown }; + assert.match(String(body.error), error, name); + assert.equal(body.tx, undefined, `${name}: no broadcastable transaction`); + assert.ok(calls.includes("getNetworkInfo"), name); + assert.ok(!calls.includes("getAddressNonce"), `${name}: no write nonce is requested`); + } + // An operator who pins that exact dirty build gets the transaction. + process.env.DEMOS_NODE_BUILDS = `${cleanBuild.commit}+dirty@${cleanBuild.builtAt}`; + node = { nodeVersion: dirty, chain: PINNED_DEMOS_CHAIN_IDENTITY }; + const pinned = await POST(request({ ...finalInput, listingSignature: await listingSignature() })); + assert.equal(pinned.status, 200); + assert.ok((await pinned.json() as { tx?: unknown }).tx); + } finally { + delete process.env.DEMOS_NODE_BUILDS; + globalThis.fetch = originalFetch; + } +}); + test("publisher rejects a metered listing without its deterministic unit", async () => { const response = await POST(request({ ...base, diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index aa7ca95..7c9a8a6 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { NextRequest } from "next/server"; -import { isAnchorReceipt } from "@kynesyslabs/dacs/artifacts"; import { canonicalize, contentHash, listingAddress, sha256Hex } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; @@ -10,15 +9,10 @@ const { POST } = await import("../app/api/dacs/confirm-listing/route.js"); const { PINNED_DEMOS_CHAIN_IDENTITY, nodeBuildRejection, pinnedChainIdentity, pinnedNodeBuilds } = await import( "../src/catalog/nodeDeployment.js" ); -const { decodeDemosAnchorReceiptProof, demosSignedTransactionProofHash } = await import( +const { demosSignedTransactionProofHash } = await import( "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js" ); -/** - * The receipt carries the SDK's portable Demos proof, restated as `included`. - * The SDK decoder reads only `finalized` receipts, so decode the same proof. - */ -const proofOf = (receipt: unknown) => - decodeDemosAnchorReceiptProof({ ...(receipt as Record), state: "finalized" } as never); +type Observation = Record & { chainIdentity: string; signedTransactionHash: string; nativeRead: Record }; const privateKey = privateKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))); const keyHex = Buffer.from(rawPublicKey(publicKeyFromSeed(Uint8Array.from(Buffer.alloc(32, 21))))).toString("hex"); @@ -219,36 +213,44 @@ function stubChain(overrides: { return calls; } -test("listing confirmation verifies coordinates, owner, identity, signature, hash, and tuple", async () => { +test("a node-reported inclusion is an unauthenticated observation, never a receipt or a confirmation", async () => { const originalFetch = globalThis.fetch; stubChain(); try { const response = await POST(request(coordinates)); assert.equal(response.status, 200); const body = await response.json() as Record; - assert.equal(body.confirmed, true); - assert.equal(body.state, "included-and-verified"); - assert.equal(body.finality, "not-verified", "inclusion is never labelled final"); + // DEMOS-MAPPING SR-2: an `included` Demos write is also final, and neither is + // verified here, so LP-1 is unmet and the browser must not register. + assert.equal(body.confirmed, false); + assert.equal(body.state, "finality-unverified"); + assert.match(String(body.error), /cannot verify Demos finality \(DACS-1 LP-1\)/); + assert.equal(body.authenticated, false); + assert.equal(body.anchorReceipt, undefined, "no CORE §5.1 AnchorReceipt is issued"); 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); - const receipt = body.anchorReceipt as Record; - assert.equal(isAnchorReceipt(receipt), true); - assert.equal(receipt.state, "included"); - assert.equal(receipt.observationDisposition, "established"); - assert.equal(receipt.logicalAddress, logicalAddress); - assert.equal(receipt.nativeAddress, anchorAddress); - assert.equal(receipt.contentHash, listingHash); - assert.deepEqual(receipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); - // node2 serves block timestamps in seconds; the receipt carries milliseconds. - assert.deepEqual(receipt.blockRef, { id: blockHash, height: "5", timestamp: 1_786_360_000_000 }); - const proof = proofOf(receipt); - assert.equal(proof.chainIdentity, chainId, "block zero answers `error`, so block one's predecessor names the chain"); - // Portable: the SDK verifier re-reads getTxByHash, whose signature is an object. - assert.equal(proof.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction))); - assert.equal(proof.nativeRead.metadataHash, sha256Hex(canonicalize(metadata))); + const observation = body.nodeObservation as Observation; + assert.equal(observation.logicalAddress, logicalAddress); + assert.equal(observation.nativeAddress, anchorAddress); + assert.equal(observation.contentHash, listingHash); + assert.equal(observation.writer, sellerClaim); + assert.equal(observation.transactionRef, transactionHash); + assert.equal(observation.nodeBuild, pinnedCommit); + assert.equal(observation.blockNumber, 5); + assert.equal(observation.blockHash, blockHash); + // node2 serves block timestamps in seconds; the observation carries milliseconds. + assert.equal(observation.blockTimestamp, 1_786_360_000_000); + assert.equal( + observation.validationDataHash, + sha256Hex(canonicalize({ signatures: { [`0x${"aa".repeat(32)}`]: `0x${"bb".repeat(64)}` } })), + ); + assert.equal(observation.chainIdentity, chainId, "block zero answers `error`, so block one's predecessor names the chain"); + // The SDK's proof hash of the getTxByHash form, whose signature is an object. + assert.equal(observation.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction))); + assert.equal(observation.nativeRead.metadataHash, sha256Hex(canonicalize(metadata))); } finally { globalThis.fetch = originalFetch; } @@ -262,11 +264,11 @@ test("listing confirmation reads a browser-supplied transactionRef by hash", asy const calls = stubChain({ history: false }); const response = await POST(request({ ...coordinates, transactionRef })); assert.equal(response.status, 200, transactionRef); - const body = await response.json() as { state: string; anchorReceipt: Record }; - assert.equal(body.state, "included-and-verified"); - assert.deepEqual(body.anchorReceipt.transactionRef, { kind: "demos-storage-program", value: transactionHash }); + const body = await response.json() as { state: string; nodeObservation: Observation }; + assert.equal(body.state, "finality-unverified"); + assert.equal(body.nodeObservation.transactionRef, transactionHash); assert.equal( - proofOf(body.anchorReceipt).signedTransactionHash, + body.nodeObservation.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction)), ); assert.ok(calls.includes("getTxByHash")); @@ -275,7 +277,9 @@ test("listing confirmation reads a browser-supplied transactionRef by hash", asy stubChain(); const other = await POST(request({ ...coordinates, transactionRef: "ef".repeat(32) })); assert.equal(other.status, 202); - assert.equal((await other.json() as { state: string }).state, "inclusion-pending"); + const otherBody = await other.json() as { state: string; confirmed: unknown }; + assert.equal(otherBody.state, "inclusion-pending"); + assert.equal(otherBody.confirmed, false); const malformed = await POST(request({ ...coordinates, transactionRef: "not-a-hash" })); assert.equal(malformed.status, 400); } finally { @@ -303,7 +307,7 @@ test("listing confirmation never registers before the write is in a node-confirm const body = await response.json() as Record; assert.equal(body.confirmed, false); assert.equal(body.state, "inclusion-pending"); - assert.equal(body.anchorReceipt, undefined); + assert.equal(body.nodeObservation, undefined); } } finally { globalThis.fetch = originalFetch; @@ -323,8 +327,9 @@ test("listing confirmation rejects native metadata that does not bind the signed const response = await POST(request(coordinates)); assert.equal(response.status, 409, Object.keys(overrides).join("+")); const body = await response.json() as Record; + assert.equal(body.confirmed, false); assert.equal(body.state, "inclusion-failed"); - assert.equal(body.anchorReceipt, undefined); + assert.equal(body.nodeObservation, undefined); } } finally { globalThis.fetch = originalFetch; @@ -337,7 +342,7 @@ test("listing confirmation distinguishes pending visibility from binding failure globalThis.fetch = async () => new Response("not found", { status: 404 }); const pending = await POST(request(coordinates)); assert.equal(pending.status, 202); - assert.equal((await pending.json()).state, "not-visible"); + assert.deepEqual(await pending.json(), { confirmed: false, state: "not-visible" }); globalThis.fetch = async () => Response.json({ success: true, @@ -347,7 +352,9 @@ test("listing confirmation distinguishes pending visibility from binding failure }); const mismatch = await POST(request(coordinates)); assert.equal(mismatch.status, 409); - assert.equal((await mismatch.json()).state, "binding-mismatch"); + const mismatchBody = await mismatch.json(); + assert.equal(mismatchBody.confirmed, false); + assert.equal(mismatchBody.state, "binding-mismatch"); } finally { globalThis.fetch = originalFetch; } @@ -359,20 +366,22 @@ test("listing confirmation rejects valid bytes under the wrong expected content try { const response = await POST(request({ ...coordinates, contentHash: "ef".repeat(32) })); assert.equal(response.status, 409); - assert.equal((await response.json()).state, "verification-failed"); + const body = await response.json(); + assert.equal(body.confirmed, false); + assert.equal(body.state, "verification-failed"); } finally { globalThis.fetch = originalFetch; } }); -/** The receipt's chain identity when `pinned` is the pinned chain, or the confirmation state. */ +/** The observation's chain identity when `pinned` is the pinned chain, or the confirmation state. */ async function chainIdentityFor(overrides: Parameters[0], pinned = chainId): Promise { stubChain(overrides); process.env.DEMOS_CHAIN_IDENTITY = pinned; try { const response = await POST(request(coordinates)); - const body = await response.json() as { state: string; anchorReceipt?: unknown }; - return response.status === 200 ? proofOf(body.anchorReceipt).chainIdentity : body.state; + const body = await response.json() as { state: string; nodeObservation?: Observation }; + return response.status === 200 ? body.nodeObservation!.chainIdentity : body.state; } finally { process.env.DEMOS_CHAIN_IDENTITY = chainId; } @@ -469,20 +478,25 @@ test("a by-hash write at another locator or with other content never stands in f stubChain({ history: false, byHash: { content } }); const response = await POST(request({ ...coordinates, transactionRef: transactionHash })); assert.equal(response.status, 202, name); - assert.equal((await response.json() as { state: string }).state, "inclusion-pending", name); + const body = await response.json() as { state: string; confirmed: unknown }; + assert.equal(body.state, "inclusion-pending", name); + assert.equal(body.confirmed, false, name); } } finally { globalThis.fetch = originalFetch; } }); -test("only the pinned Demos deployment's report becomes a receipt", async () => { +test("only the pinned Demos deployment's report becomes an observation", async () => { const originalFetch = globalThis.fetch; const node2 = { ...cleanBuild, dirty: true, builtAt: "2026-10-14T16:45:24Z" }; const confirm = async (overrides: Parameters[0]) => { stubChain(overrides); const response = await POST(request(coordinates)); - return { status: response.status, body: await response.json() as { state: string; error?: string } }; + return { + status: response.status, + body: await response.json() as { state: string; error?: string; confirmed?: unknown; nodeObservation?: Observation }, + }; }; try { for (const [name, nodeVersion, reason] of [ @@ -493,16 +507,19 @@ test("only the pinned Demos deployment's report becomes a receipt", async () => ] as const) { const { status, body } = await confirm({ nodeVersion }); assert.equal(status, 409, name); + assert.equal(body.confirmed, false, name); assert.equal(body.state, "inclusion-failed", name); assert.match(body.error ?? "", reason, name); } // An operator may pin exactly one observed dirty build, never dirty builds in general. process.env.DEMOS_NODE_BUILDS = `${pinnedCommit}+dirty@2026-10-14T16:45:24Z`; - assert.equal((await confirm({ nodeVersion: node2 })).status, 200); + const pinnedDirty = await confirm({ nodeVersion: node2 }); + assert.equal(pinnedDirty.status, 200); + assert.equal(pinnedDirty.body.nodeObservation?.nodeBuild, `${pinnedCommit}+dirty@2026-10-14T16:45:24Z`); assert.equal((await confirm({ nodeVersion: { ...node2, builtAt: "2026-10-15T00:00:00Z" } })).status, 409); assert.equal((await confirm({})).status, 409, "the clean build is not in an explicit list that omits it"); delete process.env.DEMOS_NODE_BUILDS; - // A node on another chain never issues a receipt for the pinned one. + // A node on another chain is never reported as the pinned one. process.env.DEMOS_CHAIN_IDENTITY = "aa".repeat(32); const otherChain = await confirm({}); assert.equal(otherChain.status, 409); diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index 55c06ec..f663503 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -5,21 +5,20 @@ import { join } from "node:path"; import test from "node:test"; import { NextRequest } from "next/server"; -import { isAnchorReceipt, isListing } from "@kynesyslabs/dacs/artifacts"; -import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical"; +import { isListing } from "@kynesyslabs/dacs/artifacts"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; import type { ListingSummary, SellerRecord } from "../src/catalog/types.js"; // Directory listing admission against what the pinned SDK defines for a // Listing: DPA-1 verification methods, transaction readiness (only the SDK's -// ordered reader grants it), and CORE §5.1 anchor receipts. +// ordered reader grants it) and catalog projection. const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-listing-admission-")); process.env.DACS_DIRECTORY_DATA = dataDirectory; const store = await import("../src/catalog/store.js"); const { verifyListingResult } = await import("../src/catalog/listingVerification.js"); const { indexRegistration } = await import("../src/catalog/indexer.js"); const { DIRECTORY_TRANSACTION_READINESS, buildDirectoryServiceProfile } = await import("../src/catalog/inspection.js"); -const { listingAnchorReceiptRejection } = await import("../src/catalog/listingFinality.js"); const { GET: listingsGet } = await import("../app/api/dacs/listings/route.js"); const { GET: sellerGet } = await import("../app/api/dacs/sellers/[primaryClaimRef]/route.js"); @@ -202,58 +201,6 @@ test("published summaries never claim transaction readiness the Directory did no ); }); -test("registration admits only an SDK-shaped, established, included receipt for this exact write", () => { - const listingContentHash = "d".repeat(64); - const expected = { - anchorAddress: `stor-${"5".repeat(40)}`, - logicalAddress: listingAddress(seller, "receipt-listing", 1), - listingContentHash, - sellerClaim: seller, - transactionRef: "e".repeat(64), - }; - const receipt: Obj = { - receiptVersion: "1", - substrate: "demos", - finalityProfile: "demos-bft-confirmed-native-read", - logicalAddress: expected.logicalAddress, - nativeAddress: expected.anchorAddress, - contentHash: listingContentHash, - transactionRef: { kind: "demos-storage-program", value: expected.transactionRef }, - writer: seller, - nonce: "7", - state: "included", - observationDisposition: "established", - observedAt: 1_786_360_000_000, - blockRef: { id: "block-5", height: "5", timestamp: 1_786_360_000_000 }, - evidence: { kind: "demos-bft-write-proof-v1", value: "proof" }, - }; - assert.equal(isAnchorReceipt(receipt), true); - assert.equal(listingAnchorReceiptRejection(receipt, expected), null); - - const { blockRef: _blockRef, ...withoutBlock } = receipt; - const { evidence: _evidence, ...withoutEvidence } = receipt; - const rejections: Record = { - "missing evidence": [withoutEvidence, /not a CORE §5\.1 AnchorReceipt/], - "unknown member": [{ ...receipt, trustLevel: "high" }, /not a CORE §5\.1 AnchorReceipt/], - "included without a block": [withoutBlock, /not a CORE §5\.1 AnchorReceipt/], - // Demos finality is not verified, so a receipt claiming it is never admitted. - "finalized claim": [{ ...receipt, state: "finalized" }, /does not establish an included write/], - "only accepted": [{ ...receipt, state: "accepted" }, /does not establish an included write/], - "indeterminate observation": [ - { ...receipt, observationDisposition: "indeterminate", preservedReceiptHash: "f".repeat(64) }, - /does not establish an included write/, - ], - "another logical address": [{ ...receipt, logicalAddress: listingAddress(seller, "receipt-listing", 2) }, /does not bind/], - "another native address": [{ ...receipt, nativeAddress: `stor-${"6".repeat(40)}` }, /does not bind/], - "another content hash": [{ ...receipt, contentHash: "0".repeat(64) }, /does not bind/], - "another writer": [{ ...receipt, writer: `did:demos:agent:${"7".repeat(64)}` }, /does not bind/], - "another transaction": [{ ...receipt, transactionRef: { kind: "demos-storage-program", value: "1".repeat(64) } }, /does not bind/], - }; - for (const [label, [candidate, reason]] of Object.entries(rejections)) { - assert.match(listingAnchorReceiptRejection(candidate, expected) ?? "admitted", reason, label); - } -}); - // Conforming Listings that the previous SDK pin (f68fed1) refused: APR-1 // pay-alternative, CORE CF-5 nesting through 128 levels, and CORE B.1 CF-3 // advisory ClaimReference parameters. Admission must agree with the SDK. diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index c008450..6af1d9e 100644 --- a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts +++ b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts @@ -31,6 +31,8 @@ const ownerOf = (did: string) => `0x${did.slice(-64)}`; const outsider = `did:demos:agent:${"d".repeat(64)}`; const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; const offline = async (): Promise => { throw new Error("identity unavailable"); }; +/** The stubbed node stands for the pinned deployment that reindexAll checks first. */ +const pinnedDeployment = async () => ({ ok: true as const }); const PREFIXES: Record = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", @@ -154,7 +156,7 @@ test("reindex records a deeply nested rejected artifact as bounded metadata and }, [deepMarker, validMarker, listingAnchor]); resetIndex([{ primaryClaim: seller, displayName: "Seller", listingAnchors: [listingAnchor] }], 42); - await reindexAll({ log: () => {}, resolveIdentities: offline }); + await reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: pinnedDeployment }); const catalog = store.loadCatalog(); assert.ok(catalog.generatedAt > 42); @@ -421,7 +423,7 @@ test("malformed bundles and a deal that cannot be summarized stay unverified whi assert.equal(byJob.get("job-valid")?.refsVerified, true); assert.equal(record.reputation.bundleCount, 1); resetIndex([registration], 45); - await reindexAll({ log: () => {}, resolveIdentities: offline }); + await reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: pinnedDeployment }); const db = new Database(join(dataDirectory, "directory.sqlite"), { readonly: true }); try { const run = db.prepare("SELECT * FROM scan_runs ORDER BY id DESC LIMIT 1").get() as { omitted_deals: number }; @@ -469,7 +471,7 @@ test("reindex drops malformed carried bindings and still indexes every later sel { primaryClaim: seller, displayName: "Seller", listingAnchors: [listingAnchor] }, ], 43); - await reindexAll({ log: () => {}, resolveIdentities: offline }); + await reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: pinnedDeployment }); const catalog = store.loadCatalog(); assert.ok(catalog.generatedAt > 43); @@ -486,7 +488,7 @@ test("a seller whose record cannot be indexed is logged and omitted while later resetIndex([broken, { primaryClaim: seller, displayName: "Seller", listingAnchors: [listingAnchor] }], 44); const lines: string[] = []; - await reindexAll({ log: (line) => lines.push(line), resolveIdentities: offline }); + await reindexAll({ log: (line) => lines.push(line), resolveIdentities: offline, nodeDeployment: pinnedDeployment }); const catalog = store.loadCatalog(); assert.ok(catalog.generatedAt > 44); diff --git a/reference-implementations/dacs-directory/test/rail-registry.test.ts b/reference-implementations/dacs-directory/test/rail-registry.test.ts index aa4d741..9be25b4 100644 --- a/reference-implementations/dacs-directory/test/rail-registry.test.ts +++ b/reference-implementations/dacs-directory/test/rail-registry.test.ts @@ -14,6 +14,7 @@ import { railAuthorityPermitsPublication, } from "../src/catalog/listingOptions.js"; import { resolveListingRails } from "../src/sdkListingValidation.js"; +import { modelSignedRailSnapshot } from "./signed-rail-snapshot.js"; const dataDir = mkdtempSync(join(tmpdir(), "dacs-rail-registry-")); process.env.DACS_DIRECTORY_DATA = join(dataDir, "directory.sqlite"); @@ -59,7 +60,7 @@ test("every publishable rail is a DACS-4 §9.4.2 registry ID with its registered } }); -test("a /register-built Listing passes every LRR check a reader using the Standard registry can verify", async () => { +test("/register refuses an unverified rail before any signature; a signed-snapshot Listing passes every LRR check the Standard registry can verify", async () => { const seed = Uint8Array.from(Buffer.alloc(32, 31)); const privateKey = privateKeyFromSeed(seed); const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; @@ -69,9 +70,13 @@ test("a /register-built Listing passes every LRR check a reader using the Standa body: JSON.stringify(body), }); const originalFetch = globalThis.fetch; - globalThis.fetch = async (_input, init) => init?.method === "POST" - ? Response.json({ result: 200, response: JSON.parse(String(init.body)).params?.[0]?.message === "searchStoragePrograms" ? [] : 0 }) - : new Response(null, { status: 404 }); + let rpcCalls = 0; + globalThis.fetch = async (_input, init) => { + rpcCalls += 1; + return init?.method === "POST" + ? Response.json({ result: 200, response: JSON.parse(String(init.body)).params?.[0]?.message === "searchStoragePrograms" ? [] : 0 }) + : new Response(null, { status: 404 }); + }; try { for (const option of PUBLISHABLE_RAIL_OPTIONS) { const base = { @@ -83,22 +88,41 @@ test("a /register-built Listing passes every LRR check a reader using the Standa delivery: ["deliver-storage-program"], pricing: { kind: "fixed", amount: "0.01", currency: option.phaseKind === "pay-dem" ? "DEM" : "USD" }, }; - const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; - const response = await POST(request({ - ...base, - identityPresentedAt: identity.identityPresentedAt, - identitySignature: Buffer.from(ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey)).toString("hex"), - })); - assert.equal(response.status, 200, option.railId); - const preview = await response.json() as { + // LP-6: the shipped table is unsigned, so publication is refused before + // the seller signs anything or the node is asked for anything. + rpcCalls = 0; + const refused = await POST(request(base)); + assert.equal(refused.status, 409, option.railId); + const refusal = await refused.json() as Record; + assert.match(String(refusal.error), /indeterminate \(pa1-definition-unverifiable\), not verified \(DACS-1 LP-6\)/); + assert.deepEqual(refusal.railResolution, { + disposition: "indeterminate", reason: "pa1-definition-unverifiable", authorityBasis: "pa1-in-code", + }, option.railId); + assert.equal(refusal.identityMessage, undefined, option.railId); + assert.equal(refusal.tx, undefined, option.railId); + assert.equal(rpcCalls, 0, option.railId); + + const restore = modelSignedRailSnapshot(); + let preview: { listing: { pipeline: Array<{ kind: string; parameters?: Record }>; acceptedRails: Array<{ railId: string }> }; railResolution: { disposition: string; reason: string; authorityBasis: string }; }; + try { + const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; + const response = await POST(request({ + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature: Buffer.from(ed25519Sign(Buffer.from(identity.identityMessage, "utf8"), privateKey)).toString("hex"), + })); + assert.equal(response.status, 200, option.railId); + preview = await response.json() as typeof preview; + } finally { + restore(); + } assert.deepEqual(preview.listing.acceptedRails, [{ railId: option.railId }]); assert.deepEqual(preview.listing.pipeline.find((phase) => phase.kind === option.phaseKind)?.parameters, { rail: option.railId }); - assert.deepEqual(preview.railResolution, { - disposition: "indeterminate", reason: "pa1-definition-unverifiable", authorityBasis: "pa1-in-code", - }, option.railId); + assert.equal(preview.railResolution.disposition, "verified", option.railId); + assert.equal(preview.railResolution.authorityBasis, "pa1-in-code", option.railId); // Every LRR check passes against the Standard's own table except the missing snapshot signature. const independent = resolveAgainstStandard(preview.listing); assert.equal(independent.disposition, "indeterminate", `${option.railId}: ${independent.reason}`); @@ -124,11 +148,11 @@ test("the catalog projection keeps every registry rail ID for the §6.3.6 rail f assert.deepEqual(presentation.rails, registry.rails.map((rail) => rail.railId)); }); -test("the publisher never asserts a PA-1 signature it did not verify, and refuses every other rail result", () => { +test("the publisher never asserts a PA-1 signature it did not verify, and publishes only on LP-6 verified", () => { for (const definition of PUBLISHER_IN_CODE_RAIL_DEFINITIONS) assert.equal(definition.signatureValid, false, definition.railId); - assert.equal(railAuthorityPermitsPublication({ disposition: "indeterminate", reason: "pa1-definition-unverifiable" }), true); - assert.equal(railAuthorityPermitsPublication({ disposition: "verified", reason: "verified-pa1" }), true); + assert.equal(railAuthorityPermitsPublication({ disposition: "verified" }), true); for (const result of [ + { disposition: "indeterminate", reason: "pa1-definition-unverifiable" }, { disposition: "rejected", reason: "unknown-rail" }, { disposition: "rejected", reason: "phase-handler-mismatch" }, { disposition: "indeterminate", reason: "pa1-not-accepted" }, diff --git a/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts b/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts index caf11ec..7e859ee 100644 --- a/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts +++ b/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts @@ -52,7 +52,7 @@ test("reindex clears replaced-chain discoveries and rescans from genesis in one store.recordArtifactFailure(`stor-${"c".repeat(40)}`, "listing", "STORAGE_UNREADABLE", "old chain", 1); const logs: string[] = []; - const result = await reindexAll({ log: (line) => logs.push(line) }); + const result = await reindexAll({ log: (line) => logs.push(line), nodeDeployment: async () => ({ ok: true as const }) }); assert.equal(result.cursor, 3, "finality depth leaves the newest two transactions for replay"); assert.equal(store.loadScanState().schemaVersion, 10); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index 3adc907..fc9a5d5 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -36,6 +36,8 @@ const outsider = `did:demos:agent:${"d".repeat(64)}`; const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; const range = (from: number, count: number) => Array.from({ length: count }, (_, index) => locator(from + index)); const offline = async (): Promise => { throw new Error("identity unavailable"); }; +/** The stubbed node stands for the pinned deployment that reindexAll checks first. */ +const pinnedDeployment = async () => ({ ok: true as const }); const signature = async (hash: string, prefix: string, signer: number, encoding: "hex" | "base64url" = "hex") => Buffer.from(await ed25519Sign(Buffer.from(prefix + hash), privateKeyFromSeed(seeds[signer]))).toString(encoding); @@ -146,7 +148,7 @@ const sqlWork = { enabled: false, calls: 0, rows: 0 }; } const later: Registration = { primaryClaim: outsider, displayName: "Later", listingAnchors: [] }; const sellerRegistration: Registration = { primaryClaim: seller, displayName: "Seller", listingAnchors: [locator(1)] }; -const reindex = () => reindexAll({ log: () => {}, resolveIdentities: offline }); +const reindex = () => reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: pinnedDeployment }); const listingStatus = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]?.status; const lastRun = () => sql("SELECT * FROM scan_runs ORDER BY id DESC LIMIT 1")[0]; diff --git a/reference-implementations/dacs-directory/test/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index bd48121..e8c1f8d 100644 --- a/reference-implementations/dacs-directory/test/scan-robustness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-robustness.test.ts @@ -34,6 +34,8 @@ const dealKey = (jobId: string) => `${ownerOf(buyer)}\n${jobId}`; const outsider = `did:demos:agent:${"d".repeat(64)}`; const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; const offline = async (): Promise => { throw new Error("identity unavailable"); }; +/** The stubbed node stands for the pinned deployment that reindexAll checks first. */ +const pinnedDeployment = async () => ({ ok: true as const }); const PREFIXES: Record = { listing: "dacs-listing:v1:", agreement: "dacs-agreement:v1:", evidence: "dacs-evidence:v1:", @@ -137,7 +139,7 @@ const artifactRow = (at: string) => { test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); const later: Registration = { primaryClaim: outsider, displayName: "Later", listingAnchors: [] }; -const reindex = () => reindexAll({ log: () => {}, resolveIdentities: offline }); +const reindex = () => reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: pinnedDeployment }); function sql(query: string) { const db = new Database(join(dataDirectory, "directory.sqlite")); try { return db.prepare(query).all() as Record[]; } finally { db.close(); } @@ -350,6 +352,26 @@ test("omitted binding counts are persisted in scan diagnostics", async () => { assert.equal(scanCounts().omitted_deals, 0); }); +test("a node outside the pinned deployment fails the pass before the catalog or scan state changes", async () => { + resetIndex([later], 13); + chain({ [locator(91)]: { data: JSON.stringify(listing) } }, [locator(91)]); + const before = store.loadScanState(); + await assert.rejects( + reindexAll({ + log: () => {}, + resolveIdentities: offline, + nodeDeployment: async () => ({ ok: false, reason: "Demos node build 61b8e75+dirty@x is not pinned" }), + }), + /catalog not updated: Demos node build 61b8e75\+dirty@x is not pinned/, + ); + assert.equal(store.loadCatalog().generatedAt, 13); + assert.deepEqual(store.loadScanState(), before); + // Without an injected preflight the pass asks the node itself; this stub reports no build. + await assert.rejects(reindexAll({ log: () => {}, resolveIdentities: offline })); + assert.equal(store.loadCatalog().generatedAt, 13); + assert.deepEqual(store.loadScanState(), before); +}); + test("a SQLite failure during seller indexing fails the pass and records its status", async () => { resetIndex([{ primaryClaim: buyer, displayName: "Mismatched seller", listingAnchors: [locator(90)] }, later], 12); chain({ [locator(90)]: { data: JSON.stringify(listing) } }, [locator(90)]); From 3d38d2538a4b13b4a3354ed591b56149cdd08897 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:30:53 +0000 Subject: [PATCH 28/37] Hold index-now to the node pin and record refused reindex runs POST /api/dacs/index-now wrote the public catalog from whatever node it read. It now checks the configured node's reported build and chain inside the data lock before indexing, as reindexAll does: 409 when the node is outside the pin, 503 when it cannot be read, and the catalog is left untouched in both cases. A reindex refused by that check, or one whose check cannot read the node, is now recorded as a failed scan run without touching the scan state or the catalog, so GET /api/dacs/status no longer shows the last successful pass while the catalog is frozen. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 2 +- .../app/api/dacs/index-now/route.ts | 12 +- .../dacs-directory/src/catalog/reindexCore.ts | 14 ++- .../dacs-directory/test/index-now.test.ts | 112 ++++++++++++++++++ .../test/scan-robustness.test.ts | 24 +++- 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/index-now.test.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index f8e5d11..463a0f4 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -123,7 +123,7 @@ continue. Moving `SDK_REV` therefore also means rebuilding once and updating `SD |---|---|---| | `DEMOS_RPC` | No | Demos RPC base URL; defaults to the public testnet endpoint | | `DEMOS_CHAIN_IDENTITY` | No | Chain the Directory's node must serve (block one's `previousHash`); defaults to `83082fbb…7dd4`, the chain node2 serves. On another chain, reindexing fails without changing the catalog, `build-listing` returns no transaction and `confirm-listing` reports nothing | -| `DEMOS_NODE_BUILDS` | No | Comma-separated node builds the Directory accepts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. The node reports its own build, so this detects drift and does not authenticate the node. node2 currently reports a dirty build, so **`npm run index` and `/api/dacs/reindex` fail without changing the catalog** until node2 is rebuilt clean or the operator pins that exact build | +| `DEMOS_NODE_BUILDS` | No | Comma-separated node builds the Directory accepts, from `getNetworkInfo`'s `nodeVersion`: `` for a clean build, or `+dirty@` to accept exactly one observed build with uncommitted changes. Defaults to the clean `61b8e75…` build. The node reports its own build, so this detects drift and does not authenticate the node. node2 currently reports a dirty build, so **`npm run index`, `/api/dacs/reindex` and `/api/dacs/index-now` fail without changing the catalog** until node2 is rebuilt clean or the operator pins that exact build. A refused reindex is recorded as a `failed` run, so `GET /api/dacs/status` shows that the catalog is not advancing | | `NEXT_PUBLIC_DEMOS_RPC` | No | Credential-free public HTTPS Demos RPC advertised to machine clients; defaults to the public testnet endpoint and deliberately does not inherit `DEMOS_RPC` | | `DACS_ADMIN_TOKEN` | Production | Bearer token for the operational reindex endpoints | | `DACS_SDK_GITHUB_TOKEN` | GitHub deploy | Fine-grained, read-only token for cloning the pinned private SDK during the build; not needed after the SDK is published | diff --git a/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts b/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts index f5d7214..64255e3 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/index-now/route.ts @@ -1,10 +1,11 @@ /** * POST /api/dacs/index-now { claim } — index one registration immediately * (fresh publishes shouldn't wait for the next timer pass). Full chain - * verification, same as the batch path. + * verification and the same node pin as the batch path. */ import { NextRequest, NextResponse } from "next/server"; import { indexRegistration } from "@/src/catalog/indexer"; +import { pinnedNodeDeployment } from "@/src/catalog/listingFinality"; import { requireAdmin } from "@/src/catalog/security"; import { loadCatalog, loadRegistrations, saveCatalog, withDataLock } from "@/src/catalog/store"; @@ -18,6 +19,15 @@ export async function POST(req: NextRequest) { if (!reg) return NextResponse.json({ error: "no registration for that claim" }, { status: 404 }); return withDataLock("reindex", async () => { + // As in reindexAll: nothing from a node outside the pinned build and chain + // reaches the public catalog, and a node that cannot be read changes nothing. + const deployment = await pinnedNodeDeployment().catch(() => null); + if (!deployment) { + return NextResponse.json({ error: "could not read the Demos node's build and chain; catalog not updated" }, { status: 503 }); + } + if (!deployment.ok) { + return NextResponse.json({ error: `catalog not updated: ${deployment.reason}` }, { status: 409 }); + } const catalog = loadCatalog(); const prior = catalog.sellers.find((s) => s.primaryClaim === claim); const record = await indexRegistration(reg, prior); diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index cc2e798..ca0a16b 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -97,8 +97,18 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise ({ + ok: false as const, + reason: `could not read the Demos node's build and chain: ${error instanceof Error ? error.message : String(error)}`, + })); + if (!deployment.ok) { + const error = `catalog not updated: ${deployment.reason}`; + const { lastSeenTxId } = loadScanState(); + finishScanRun(beginScanRun(lastSeenTxId), { ...omitted, toTx: lastSeenTxId, txs: 0, artifacts: 0, rejected: 0, error }); + throw new Error(error); + } // ── Passive discovery, incremental: walk latest → cursor, union into the // accumulated scan state. Discoveries persist while the chain does; a diff --git a/reference-implementations/dacs-directory/test/index-now.test.ts b/reference-implementations/dacs-directory/test/index-now.test.ts new file mode 100644 index 0000000..1e665ea --- /dev/null +++ b/reference-implementations/dacs-directory/test/index-now.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; +import { contentHash } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; + +// POST /api/dacs/index-now writes the public catalog, so it is held to the +// same node pin as reindexAll (scan-robustness.test.ts). +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-index-now-")); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +process.env.DACS_ADMIN_TOKEN = "index-now-admin"; +delete process.env.DEMOS_NODE_BUILDS; +delete process.env.DEMOS_CHAIN_IDENTITY; +const store = await import("../src/catalog/store.js"); +const { PINNED_DEMOS_CHAIN_IDENTITY, PINNED_DEMOS_NODE_BUILDS } = await import("../src/catalog/nodeDeployment.js"); +const { POST } = await import("../app/api/dacs/index-now/route.js"); +test.after(() => rmSync(dataDirectory, { recursive: true, force: true })); + +const seed = Uint8Array.from(Buffer.alloc(32, 22)); +const seller = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; +const anchor = `stor-${"b".repeat(40)}`; +const listingScope = { + listingId: "svc", listingVersion: 1, agentId: seller, serviceId: "svc", name: "Service", description: "Description", + claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], + supportedDelivery: ["deliver-attested-payload"], +}; +const listing = { + ...listingScope, + signature: Buffer.from(await ed25519Sign(Buffer.from("dacs-listing:v1:" + contentHash(listingScope)), privateKeyFromSeed(seed))).toString("hex"), +}; + +/** node2's getNetworkInfo `nodeVersion` for the pinned commit. */ +const cleanBuild = { commit: PINNED_DEMOS_NODE_BUILDS[0], dirty: false, builtAt: "2026-10-14T16:45:24Z" }; +let node: { nodeVersion?: Record; chain: string; down?: boolean } = { nodeVersion: cleanBuild, chain: PINNED_DEMOS_CHAIN_IDENTITY }; +let calls: string[] = []; +let storageReads = 0; +globalThis.fetch = async (input, init) => { + const path = new URL(String(input)).pathname; + if (path.startsWith("/storage-program/")) { + storageReads++; + if (path.slice("/storage-program/".length) !== anchor) return new Response(null, { status: 404 }); + return Response.json({ success: true, owner: `0x${seller.slice(-64)}`, programName: "opaque", data: listing }); + } + const call = (JSON.parse(String(init?.body)) as { params?: Array<{ message?: string; data?: { blockNumber?: number } }> }).params?.[0]; + calls.push(String(call?.message)); + if (call?.message === "getNetworkInfo") { + return node.down + ? new Response("unavailable", { status: 503 }) + : Response.json({ result: 200, response: { nodeVersion: node.nodeVersion } }); + } + // Deployed nodes answer block zero with an error; block one names the chain. + if (call?.message === "getBlockByNumber" && call.data?.blockNumber === 0) return Response.json({ result: 400, response: "error" }); + if (call?.message === "getBlockByNumber" && call.data?.blockNumber === 1) { + return Response.json({ + result: 200, + response: { id: 2, number: 1, status: "confirmed", hash: "12".repeat(32), content: { previousHash: node.chain, timestamp: 1 } }, + }); + } + return Response.json({ result: 200, response: [] }); +}; + +const indexNow = () => POST(new NextRequest("https://directory.example/api/dacs/index-now", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer index-now-admin" }, + body: JSON.stringify({ claim: seller }), +})); + +test("index-now writes nothing to the catalog unless the node is the pinned deployment", async () => { + store.saveRegistrations([{ primaryClaim: seller, displayName: "Seller", listingAnchors: [anchor] }]); + store.saveCatalog({ catalogVersion: "1", generatedAt: 7, sellers: [] }); + const dirty = { ...cleanBuild, dirty: true }; + const cases: Array<[string, typeof node, number, RegExp]> = [ + // node2 today: the pinned commit with uncommitted changes. + ["dirty build", { nodeVersion: dirty, chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, + /^catalog not updated: Demos node build 61b8e75\w+\+dirty@2026-10-14T16:45:24Z is not pinned$/], + ["another commit", { nodeVersion: { ...cleanBuild, commit: "f".repeat(40) }, chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, /is not pinned$/], + ["no build identity", { chain: PINNED_DEMOS_CHAIN_IDENTITY }, 409, /reports no build identity$/], + ["another chain", { nodeVersion: cleanBuild, chain: "aa".repeat(32) }, 409, /not the pinned chain$/], + ["unreadable node", { nodeVersion: cleanBuild, chain: PINNED_DEMOS_CHAIN_IDENTITY, down: true }, 503, + /could not read the Demos node's build and chain; catalog not updated/], + ]; + for (const [name, reply, status, error] of cases) { + node = reply; + calls = []; + storageReads = 0; + const response = await indexNow(); + assert.equal(response.status, status, name); + const body = await response.json() as { ok?: boolean; error?: string }; + assert.equal(body.ok, undefined, name); + assert.match(String(body.error), error, name); + assert.ok(calls.includes("getNetworkInfo"), name); + assert.equal(storageReads, 0, `${name}: the registration was not indexed`); + assert.deepEqual(store.loadCatalog(), { catalogVersion: "1", generatedAt: 7, sellers: [] }, name); + } + + // An operator who pins that exact dirty build indexes the seller. + process.env.DEMOS_NODE_BUILDS = `${cleanBuild.commit}+dirty@${cleanBuild.builtAt}`; + try { + node = { nodeVersion: dirty, chain: PINNED_DEMOS_CHAIN_IDENTITY }; + const pinned = await indexNow(); + assert.equal(pinned.status, 200); + assert.deepEqual(await pinned.json(), { ok: true, listings: 1, verifiedDeals: 0 }); + const catalog = store.loadCatalog(); + assert.notEqual(catalog.generatedAt, 7); + assert.deepEqual(catalog.sellers.map((s) => [s.primaryClaim, s.listings.map((l) => l.status)]), [[seller, ["active"]]]); + } finally { + delete process.env.DEMOS_NODE_BUILDS; + } +}); diff --git a/reference-implementations/dacs-directory/test/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index e8c1f8d..9d25eea 100644 --- a/reference-implementations/dacs-directory/test/scan-robustness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-robustness.test.ts @@ -356,6 +356,16 @@ test("a node outside the pinned deployment fails the pass before the catalog or resetIndex([later], 13); chain({ [locator(91)]: { data: JSON.stringify(listing) } }, [locator(91)]); const before = store.loadScanState(); + const runs = () => Number(sql("SELECT COUNT(*) count FROM scan_runs")[0].count); + const runsBefore = runs(); + /** The refused pass is the latest run, failed with its reason, and scanned nothing. */ + const assertFailedRun = (error: RegExp) => { + const run = scanCounts(); + assert.equal(run.status, "failed"); + assert.match(String(run.error), error); + assert.equal(run.txs_scanned, 0); + assert.equal(store.indexerDiagnostics().lastRun?.status, "failed", "the public status shows the refusal"); + }; await assert.rejects( reindexAll({ log: () => {}, @@ -366,10 +376,22 @@ test("a node outside the pinned deployment fails the pass before the catalog or ); assert.equal(store.loadCatalog().generatedAt, 13); assert.deepEqual(store.loadScanState(), before); + assert.equal(runs(), runsBefore + 1); + assertFailedRun(/^catalog not updated: Demos node build 61b8e75\+dirty@x is not pinned$/); + // A preflight that cannot read the node fails the pass the same way. + await assert.rejects( + reindexAll({ log: () => {}, resolveIdentities: offline, nodeDeployment: async () => { throw new Error("getNetworkInfo transport failed"); } }), + /^Error: catalog not updated: could not read the Demos node's build and chain: getNetworkInfo transport failed$/, + ); + assert.equal(store.loadCatalog().generatedAt, 13); + assert.deepEqual(store.loadScanState(), before); + assertFailedRun(/could not read the Demos node's build and chain/); // Without an injected preflight the pass asks the node itself; this stub reports no build. - await assert.rejects(reindexAll({ log: () => {}, resolveIdentities: offline })); + await assert.rejects(reindexAll({ log: () => {}, resolveIdentities: offline }), /catalog not updated: Demos node reports no build identity/); assert.equal(store.loadCatalog().generatedAt, 13); assert.deepEqual(store.loadScanState(), before); + assert.equal(runs(), runsBefore + 3); + assertFailedRun(/reports no build identity/); }); test("a SQLite failure during seller indexing fails the pass and records its status", async () => { From b518af937ec33faf7a0bef64ee8e128ed8668eaa Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:30:53 +0000 Subject: [PATCH 29/37] Pin the known-marker memory, the reputation hint and negotiation phases Tests only; the production code already behaves this way. - scan-fairness: a seller-signed revocation marker anchored by another party is verified, the published binding is gone, and the marker then answers 503, 404 or not at all. The listing must stay withheld, which only the scan state's memory of verified markers guarantees (no owner candidate, no carried binding). - current-indexer: a two-copy deal whose copies disagree on the outcome is unverified and must not enter its listing's reputationHint, even though it sits in the listing's category. - security: every publishable pricing kind maps to its negotiation phase (negotiable to RFQ, auction to sealed envelope). Co-Authored-By: Claude Opus 5.5 --- .../test/current-indexer.test.ts | 30 +++++++++++++++++++ .../dacs-directory/test/scan-fairness.test.ts | 28 +++++++++++++++++ .../dacs-directory/test/security.test.ts | 7 +++++ 3 files changed, 65 insertions(+) diff --git a/reference-implementations/dacs-directory/test/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 486b860..7ae52ac 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -1100,6 +1100,36 @@ test("a verified two-copy deal publishes exactly the evidence its authoritative assert.equal(record.reputation.bundleCount, 1); }); +test("a divergent two-copy deal in the listing's category never enters its reputationHint", async () => { + // Each copy verifies on its own, but the seller copy reports another outcome + // (#230), so the deal is unverified: neither the seller's reputation nor the + // listing's hint may count it. + const fixture = await vector("hint-divergent-job", 140); + const sellerScope = signedScope(fixture.sellerBundle, "bundle"); + sellerScope.outcome = "failed-counterparty"; + sellerScope.phaseSummary = [{ index: 2, kind: "settle", outcome: "fail", errorClass: "counterparty" }]; + const divergentSellerCopy = { + ...sellerScope, + signatures: [await sign(sellerScope, "bundle", 0, true), await sign(sellerScope, "bundle", 1, true)], + anchoredByRole: "seller", + }; + maps.set(fixture.locators.seller, divergentSellerCopy); + const deal = registeredDeal("hint-divergent-job", fixture.locators.buyer, fixture.locators.seller); + const record = await indexFixture(fixture, { deals: [deal], bundleBindings: [ + await bindingFor(deal.jobId, "buyer", fixture.locators.buyer, fixture.buyerBundle, 0), + await bindingFor(deal.jobId, "seller", fixture.locators.seller, divergentSellerCopy, 1), + ] }); + const [published] = record.deals; + assert.deepEqual( + { refsVerified: published.refsVerified, reputationEligible: published.reputationEligible, category: published.category }, + { refsVerified: false, reputationEligible: false, category: "services.test" }, + ); + const { categoryScope, completionRate, averageSellerRating, bundleCount } = record.listings[0].reputationHint!; + assert.deepEqual({ categoryScope, completionRate, averageSellerRating, bundleCount }, + { categoryScope: "services.test", completionRate: null, averageSellerRating: null, bundleCount: 0 }); + assert.equal(record.reputation.bundleCount, 0); +}); + test("a BundleBinding resolves only to the copy anchored by the role it names", async () => { // Both copies share one bundle hash; only the unhashed anchoredByRole tells them apart (BB-5). const fixture = await vector("misrole-job", 150); diff --git a/reference-implementations/dacs-directory/test/scan-fairness.test.ts b/reference-implementations/dacs-directory/test/scan-fairness.test.ts index fc9a5d5..bfb9848 100644 --- a/reference-implementations/dacs-directory/test/scan-fairness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-fairness.test.ts @@ -799,6 +799,34 @@ test("a known verified marker that answers without a readable artifact withholds } }); +test("a verified marker another party anchored is remembered by the pass and withholds its listing once unreadable", async () => { + // Only the scan state's memory of the verified marker withholds this listing: + // the writer is not the owner, and no published binding is carried. + const cases: Array<[string, (at: string) => void]> = [ + ["503", (at) => unavailable.add(at)], + ["404", (at) => answers.set(at, () => Response.json({ success: false, errorCode: "NOT_FOUND" }, { status: 404 }))], + ["fetch rejects", (at) => answers.set(at, () => { throw new TypeError("fetch failed"); })], + ]; + const summary = () => store.loadCatalog().sellers.find((s) => s.primaryClaim === seller)?.listings[0]; + for (const [name, outage] of cases) { + resetIndex([sellerRegistration, later], 42); + chain({ + [locator(1)]: { data: JSON.stringify(listing) }, + [locator(62)]: { data: JSON.stringify(marker), owner: ownerOf(outsider) }, + }, [locator(1), locator(62)]); + await reindex(); + assert.equal(summary()?.status, "revoked", `${name}: the marker was verified`); + assert.deepEqual(store.loadScanState().verifiedRevocations[listingHash], [locator(62)], `${name}: the pass saved the marker`); + // As after a pass that withheld the listing: no published binding to carry. + store.saveCatalog({ catalogVersion: "1", generatedAt: 43, sellers: [] }); + transactions = []; + outage(locator(62)); + await reindex(); + assert.equal(summary(), undefined, `${name}: the listing is withheld, not republished active`); + assert.deepEqual(store.loadScanState().verifiedRevocations[listingHash], [locator(62)], `${name}: the marker stays known`); + } +}); + test("a transient retry is read before rejected retries and the revocation it carries is applied", async () => { resetIndex([sellerRegistration, later], 10); chain({ [locator(1)]: { data: JSON.stringify(listing) }, [locator(60)]: { data: JSON.stringify(marker) } }, [locator(1), locator(60)]); diff --git a/reference-implementations/dacs-directory/test/security.test.ts b/reference-implementations/dacs-directory/test/security.test.ts index d29dfdb..89a3988 100644 --- a/reference-implementations/dacs-directory/test/security.test.ts +++ b/reference-implementations/dacs-directory/test/security.test.ts @@ -137,6 +137,13 @@ test("publisher options bind AP2 rail ids to phases and project metered pricing" assert.equal(publishableRail("ap2:stripe-paymentintents")?.availability, "operator_gated"); assert.ok(PUBLISHABLE_PRICING_KINDS.includes("metered")); assert.equal(negotiationPhaseForPricing("metered"), "negotiate-fixed-price"); + // The pinned SDK runs RFQ only on negotiable (or metered) pricing. + assert.deepEqual(PUBLISHABLE_PRICING_KINDS.map((kind) => [kind, negotiationPhaseForPricing(kind)]), [ + ["fixed", "negotiate-fixed-price"], + ["negotiable", "negotiate-rfq"], + ["auction", "negotiate-sealed-envelope"], + ["metered", "negotiate-fixed-price"], + ]); const presentation = listingPresentation({ offering: { title: "Metered API", description: "Per call", category: "services.api", tags: [] }, From c935f0706633f3636b4530a6e1f68ba2af63f961 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:30:53 +0000 Subject: [PATCH 30/37] Print the SDK digest when the app path is a symlink Node loads the main module from its real path, but argv[1] keeps the symlinked one, so the digest CLI printed nothing and setup reported an empty digest. The entry check now compares real paths. Co-Authored-By: Claude Opus 5.5 --- .../scripts/sdk-dist-digest.mjs | 10 +++++++--- .../dacs-directory/test/setup-sdk.test.mjs | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs index b46b4f5..e93f19d 100644 --- a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs +++ b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs @@ -7,7 +7,7 @@ // // usage: node scripts/sdk-dist-digest.mjs [--manifest ] import { createHash } from "node:crypto"; -import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { lstatSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -32,8 +32,12 @@ export function distManifest(root) { export const distDigest = (root) => createHash("sha256").update(distManifest(root)).digest("hex"); -// Compared as paths: a URL built from argv misreads `#`, `%` and spaces. -if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { +// Compared as real paths: a URL built from argv misreads `#`, `%` and spaces, +// and Node loads the main module from its real path, not the symlinked one. +const invokedPath = (path) => { + try { return realpathSync(path); } catch { return resolve(path); } +}; +if (process.argv[1] && fileURLToPath(import.meta.url) === invokedPath(process.argv[1])) { const [root, flag, out] = process.argv.slice(2); if (!root || (flag !== undefined && (flag !== "--manifest" || !out))) { console.error("usage: node scripts/sdk-dist-digest.mjs [--manifest ]"); diff --git a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs index 2eceda2..6f6357f 100644 --- a/reference-implementations/dacs-directory/test/setup-sdk.test.mjs +++ b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs @@ -145,19 +145,23 @@ test("a source build is accepted only from a clean pinned checkout whose output assert.match(rebuilt.manifest ?? "", /^[0-9a-f]{64} {2}index\.js\n$/); }); -test("the digest CLI prints the digest from an app path with spaces, # or %", () => { - for (const name of ["app 1", "app#1", "app%1", "app%23"]) { - const root = join(mkdtempSync(join(tmpdir(), "dacs-digest-cli-")), name); +test("the digest CLI prints the digest from an app path with spaces, # or %, or through a symlink", () => { + for (const name of ["app 1", "app#1", "app%1", "app%23", "linked app"]) { + const scratch = mkdtempSync(join(tmpdir(), "dacs-digest-cli-")); + const real = join(scratch, name === "linked app" ? "real app" : name); + // The app is run through a symlinked path; Node loads the script from its real path. + const root = join(scratch, name); try { - mkdirSync(join(root, "scripts"), { recursive: true }); - mkdirSync(join(root, "dist")); - writeFileSync(join(root, "dist/index.js"), builtIndex); - copyFileSync(digestScript, join(root, "scripts/sdk-dist-digest.mjs")); + mkdirSync(join(real, "scripts"), { recursive: true }); + mkdirSync(join(real, "dist")); + writeFileSync(join(real, "dist/index.js"), builtIndex); + copyFileSync(digestScript, join(real, "scripts/sdk-dist-digest.mjs")); + if (root !== real) symlinkSync(real, root); const run = spawnSync(process.execPath, [join(root, "scripts/sdk-dist-digest.mjs"), join(root, "dist")], { encoding: "utf8" }); assert.equal(run.status, 0, `${name}: ${run.stderr}`); assert.equal(run.stdout, `${fixtureSha}\n`, name); } finally { - rmSync(join(root, ".."), { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); } } }); From 055e5ea2491f694c7483b3bf0771ce6d849da3fc Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:29:55 +0000 Subject: [PATCH 31/37] Carry the exact presentedBy in catalog summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A current listing's §6.3.6 summary named the parameter-free registration claim as seller.primaryClaim while its revocation binding carried the exact presentedBy bytes (RB-2). For a presentedBy with CF-3 parameters the pinned SDK catalog client then rejected the whole seller page as malformed, and its catalog binding index could not resolve the listing's logical address. The summary now carries the listing's exact presentedBy. Seller records stay keyed by the CF-3 identity, and the Directory's own lookups and links (the primaryClaim and identityTier filters, the listing detail and inspect-service seller parameter, and service and seller links) compare and address sellers by that identity. The new interop test pushes every summary the listings and seller routes serve through the pinned SDK's queryListingCatalog and createCatalogBindingIndex, for active and revoked listings with a plain and a parameter-bearing presentedBy. Co-Authored-By: Claude Opus 5.5 --- .../[listingId]/[version]/route.ts | 3 +- .../listings/[listingId]/[version]/route.ts | 11 +- .../app/api/dacs/listings/route.ts | 4 +- .../dacs-directory/src/catalog/discovery.ts | 12 +- .../dacs-directory/src/catalog/indexer.ts | 9 +- .../dacs-directory/src/catalog/inspection.ts | 7 +- .../test/catalog-sdk-interop.test.ts | 138 ++++++++++++++++++ .../test/listing-admission.test.ts | 9 +- 8 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/catalog-sdk-interop.test.ts diff --git a/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts index 466d293..b7ec8a6 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts @@ -1,5 +1,6 @@ /** GET /api/dacs/inspect-service/{listingId}/{version} — verifier-ready Directory profile envelope. */ import { NextRequest, NextResponse } from "next/server"; +import { sellerIdentity } from "@/src/catalog/discovery"; import { catalogJson } from "@/src/catalog/http"; import { buildDirectoryServiceInspectionEnvelope, @@ -20,7 +21,7 @@ export async function GET( } const hit = loadCatalog() .sellers.flatMap((s) => s.listings) - .find((l) => l.listingId === listingId && String(l.version) === version && l.seller.primaryClaim === requestedSeller); + .find((l) => l.listingId === listingId && String(l.version) === version && sellerIdentity(l.seller.primaryClaim) === sellerIdentity(requestedSeller)); if (!hit) return NextResponse.json({ error: "listing not found" }, { status: 404 }); const origin = requestBaseUrl(req); diff --git a/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts index 2453ad5..b9019b4 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts @@ -6,6 +6,7 @@ import { readCounterpartyEvidenceFixtureAnchor, verifyCounterpartyEvidenceFixtureListing, } from "@/src/catalog/counterpartyEvidence"; +import { sellerIdentity } from "@/src/catalog/discovery"; import { verifyListing } from "@/src/catalog/listingVerification"; import { catalogJson } from "@/src/catalog/http"; import { inspectServicePath } from "@/src/catalog/inspection"; @@ -20,7 +21,7 @@ export async function GET( const origin = requestBaseUrl(req); const hit = loadCatalog() .sellers.flatMap((s) => s.listings) - .find((l) => l.listingId === listingId && String(l.version) === version && (!requestedSeller || l.seller.primaryClaim === requestedSeller)); + .find((l) => l.listingId === listingId && String(l.version) === version && (!requestedSeller || sellerIdentity(l.seller.primaryClaim) === sellerIdentity(requestedSeller))); if (!hit) return NextResponse.json({ error: "listing not found" }, { status: 404 }); if (hit.anchor.kind === "fixture") { const fixture = readCounterpartyEvidenceFixtureAnchor(hit.anchor.locator); @@ -30,8 +31,8 @@ export async function GET( return catalogJson(req, fixture, { lastModified: hit.catalogObservedAt, links: [ - { href: `${origin}/service/${encodeURIComponent(hit.seller.primaryClaim)}/${encodeURIComponent(hit.listingId)}/${hit.version}`, rel: "alternate", type: "text/html" }, - { href: `${origin}/api/dacs/sellers/${encodeURIComponent(hit.seller.primaryClaim)}`, rel: "seller", type: "application/json" }, + { href: `${origin}/service/${encodeURIComponent(sellerIdentity(hit.seller.primaryClaim))}/${encodeURIComponent(hit.listingId)}/${hit.version}`, rel: "alternate", type: "text/html" }, + { href: `${origin}/api/dacs/sellers/${encodeURIComponent(sellerIdentity(hit.seller.primaryClaim))}`, rel: "seller", type: "application/json" }, { href: `${origin}/schemas/listing-summary.schema.json`, rel: "describedby", type: "application/schema+json" }, ], }); @@ -51,8 +52,8 @@ export async function GET( return catalogJson(req, raw, { lastModified: hit.catalogObservedAt, links: [ - { href: `${origin}/service/${encodeURIComponent(hit.seller.primaryClaim)}/${encodeURIComponent(hit.listingId)}/${hit.version}`, rel: "alternate", type: "text/html" }, - { href: `${origin}/api/dacs/sellers/${encodeURIComponent(hit.seller.primaryClaim)}`, rel: "seller", type: "application/json" }, + { href: `${origin}/service/${encodeURIComponent(sellerIdentity(hit.seller.primaryClaim))}/${encodeURIComponent(hit.listingId)}/${hit.version}`, rel: "alternate", type: "text/html" }, + { href: `${origin}/api/dacs/sellers/${encodeURIComponent(sellerIdentity(hit.seller.primaryClaim))}`, rel: "seller", type: "application/json" }, { href: `${origin}${inspectServicePath(hit)}`, rel: "service-inspection", type: "application/json" }, { href: `${origin}/schemas/listing-summary.schema.json`, rel: "describedby", type: "application/schema+json" }, ], diff --git a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts index cc13d66..2aa412a 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts @@ -3,7 +3,7 @@ * Normative filters plus q/profile directory extensions. */ import { NextRequest, NextResponse } from "next/server"; -import { activeCatalogListings, primaryClaimMatches } from "@/src/catalog/discovery"; +import { activeCatalogListings, primaryClaimMatches, sellerIdentity } from "@/src/catalog/discovery"; import { loadCatalog } from "@/src/catalog/store"; import { parsePagination } from "@/src/catalog/pagination"; import { catalogJson } from "@/src/catalog/http"; @@ -56,7 +56,7 @@ export async function GET(req: NextRequest) { if (category && l.offering.category !== category && !l.offering.category.startsWith(category + ".")) return false; if (rail && !(l.offering.rails ?? []).includes(rail)) return false; if (profile && l.artifactProfile !== profile) return false; - if (identityTier && tierByClaim.get(l.seller.primaryClaim) !== identityTier) return false; + if (identityTier && tierByClaim.get(sellerIdentity(l.seller.primaryClaim)) !== identityTier) return false; if (primaryClaim && !primaryClaimMatches(l.seller.primaryClaim, primaryClaim)) return false; if (credential) { const required = Array.isArray(l.buyerRequirement?.required) diff --git a/reference-implementations/dacs-directory/src/catalog/discovery.ts b/reference-implementations/dacs-directory/src/catalog/discovery.ts index 8895c5d..1a482bb 100644 --- a/reference-implementations/dacs-directory/src/catalog/discovery.ts +++ b/reference-implementations/dacs-directory/src/catalog/discovery.ts @@ -1,5 +1,13 @@ +import { canonicalDemosAgentClaim } from "./claimRef.js"; import type { Catalog, ListingSummary, SellerRecord } from "./types.js"; +/** + * A current summary's `seller.primaryClaim` carries the listing's exact + * `presentedBy` bytes (§6.3.6 / RB-2), CF-3 parameters included; seller + * records, lookups and links use its parameter-free CF-3 identity. + */ +export const sellerIdentity = (claim: string): string => canonicalDemosAgentClaim(claim) ?? claim; + /** Public discovery advertises active offers only; revoked entries are history. */ export const activeCatalogListings = (catalog: Catalog): ListingSummary[] => catalog.sellers.flatMap((seller) => seller.listings).filter((listing) => listing.status === "active"); @@ -15,7 +23,7 @@ export const activeCatalogSellers = (sellers: SellerRecord[]): SellerRecord[] => /** Match either one exact canonical claim or a claim-scheme/prefix filter. */ export function primaryClaimMatches(claim: string, filter: string): boolean { - const canonicalClaim = claim.toLowerCase(); - const canonicalFilter = filter.toLowerCase(); + const canonicalClaim = sellerIdentity(claim).toLowerCase(); + const canonicalFilter = sellerIdentity(filter).toLowerCase(); return canonicalClaim === canonicalFilter || canonicalClaim.startsWith(canonicalFilter + ":"); } diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 054ba7c..4ca39b1 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -273,7 +273,14 @@ export async function indexRegistration( version, contentHash: verified.contentHash, anchor: { kind: "storage-program", locator: anchor }, - seller: { primaryClaim: reg.primaryClaim, displayName: reg.displayName }, + // §6.3.6 / RB-2: SDK catalog clients derive the logical address from, and + // match the revocation binding against, the exact `presentedBy` bytes. + seller: { + primaryClaim: verified.profile === "dacs-v0.1" + ? verified.listing.seller.identity.presentedBy + : reg.primaryClaim, + displayName: reg.displayName, + }, artifactProfile: verified.profile, publicEndpoint: safePublicEndpoint(signedSeller?.publicEndpoint), offering: { diff --git a/reference-implementations/dacs-directory/src/catalog/inspection.ts b/reference-implementations/dacs-directory/src/catalog/inspection.ts index a0b5001..47cf8a0 100644 --- a/reference-implementations/dacs-directory/src/catalog/inspection.ts +++ b/reference-implementations/dacs-directory/src/catalog/inspection.ts @@ -4,6 +4,7 @@ import type { DirectoryTransactionReadiness, ListingSummary, } from "./types.js"; +import { sellerIdentity } from "./discovery.js"; export const DIRECTORY_TRANSACTION_READINESS: DirectoryTransactionReadiness = Object.freeze({ disposition: "unassessed", @@ -69,15 +70,15 @@ const limitations: DirectoryServiceProfile["limitations"] = [ const encode = encodeURIComponent; export function servicePath(listing: ListingSummary): string { - return `/service/${encode(listing.seller.primaryClaim)}/${encode(listing.listingId)}/${listing.version}`; + return `/service/${encode(sellerIdentity(listing.seller.primaryClaim))}/${encode(listing.listingId)}/${listing.version}`; } export function listingJsonPath(listing: ListingSummary): string { - return `/api/dacs/listings/${encode(listing.listingId)}/${listing.version}?seller=${encode(listing.seller.primaryClaim)}`; + return `/api/dacs/listings/${encode(listing.listingId)}/${listing.version}?seller=${encode(sellerIdentity(listing.seller.primaryClaim))}`; } export function inspectServicePath(listing: ListingSummary): string { - return `/api/dacs/inspect-service/${encode(listing.listingId)}/${listing.version}?seller=${encode(listing.seller.primaryClaim)}`; + return `/api/dacs/inspect-service/${encode(listing.listingId)}/${listing.version}?seller=${encode(sellerIdentity(listing.seller.primaryClaim))}`; } export function inspectServiceUrl(origin: string, listing: ListingSummary): string { diff --git a/reference-implementations/dacs-directory/test/catalog-sdk-interop.test.ts b/reference-implementations/dacs-directory/test/catalog-sdk-interop.test.ts new file mode 100644 index 0000000..3c95a61 --- /dev/null +++ b/reference-implementations/dacs-directory/test/catalog-sdk-interop.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; +import { contentHash, listingAddress } from "@kynesyslabs/dacs/canonical"; +import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; +import { createCatalogBindingIndex, queryListingCatalog } from "../vendor/dacs-sdk/dist/discovery/catalog.js"; + +// §6.3.6 interop: every summary the Directory serves goes through the pinned +// SDK's catalog client, for a plain and a CF-3 parameter-bearing presentedBy. +process.env.DACS_DIRECTORY_DATA = mkdtempSync(join(tmpdir(), "dacs-directory-catalog-sdk-")); +process.env.DEMOS_RPC = "https://node.test"; +const store = await import("../src/catalog/store.js"); +const { indexRegistration } = await import("../src/catalog/indexer.js"); +const { findValidListingRevocation, verifyListing } = await import("../src/catalog/listingVerification.js"); +const listingsRoute = await import("../app/api/dacs/listings/route.js"); +const listingRoute = await import("../app/api/dacs/listings/[listingId]/[version]/route.js"); +const inspectRoute = await import("../app/api/dacs/inspect-service/[listingId]/[version]/route.js"); +const sellersRoute = await import("../app/api/dacs/sellers/[primaryClaimRef]/route.js"); + +const seed = Uint8Array.from(Buffer.alloc(32, 0x52)); +const privateKey = privateKeyFromSeed(seed); +const claim = `did:demos:agent:${Buffer.from(rawPublicKey(publicKeyFromSeed(seed))).toString("hex")}`; +const owner = `0x${claim.slice(-64)}`; +const sign = (message: string) => Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("base64url"); +const locator = (n: number) => `stor-${n.toString(16).padStart(40, "0")}`; +const offline = async (): Promise => { throw new Error("identity unavailable"); }; + +function listingFor(listingId: string, presentedBy: string): Record { + const identity: Record = { + bundleVersion: "1", presentedBy, presentedAt: 1, claims: [{ ref: presentedBy, kind: "signing-key" }], + }; + identity.presentation = { kind: "per-claim", signatures: [{ ref: presentedBy, signature: sign(`dacs-bundle-presentation:v1:${contentHash(identity)}`) }] }; + const scope: Record = { + dacsVersion: "1", listingVersion: 1, listingId, + seller: { displayName: "Interop seller", identity }, + offering: { title: listingId, description: "catalog interop", category: "conformance.signature", tags: [], deliverable: { kind: "storage-program" } }, + buyerRequirement: { requirementVersion: "1", required: [] }, + pipeline: [{ kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-dem", parameters: { rail: "demos-native:DEM" } }, { kind: "deliver-storage-program" }], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "demos-native:DEM" }], + terms: {}, validity: { notBefore: 1 }, + }; + return { ...scope, signature: { algorithm: "ed25519", signer: presentedBy, value: sign(`dacs-listing:v1:${contentHash(scope)}`) } }; +} + +/** Index one active and one revoked listing presented as `presentedBy`, then publish the catalog. */ +async function publishCatalog(presentedBy: string): Promise { + const active = listingFor("interop-active", presentedBy); + const revoked = listingFor("interop-revoked", presentedBy); + const storage: Record> = { [locator(1)]: active, [locator(2)]: revoked }; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + const at = url.pathname.startsWith("/storage-program/") ? url.pathname.slice("/storage-program/".length) : null; + if (!at) return new Response(null, { status: 503 }); + if (!storage[at]) return new Response(null, { status: 404 }); + return Response.json({ success: true, owner, programName: "opaque", data: storage[at] }); + }; + const verifiedRevoked = (await verifyListing(revoked))!; + const markerScope = { listingId: "interop-revoked", listingVersion: 1, listingContentHash: verifiedRevoked.contentHash, revokedAt: 1 }; + const marker = { ...markerScope, signature: { algorithm: "ed25519", signer: presentedBy, value: sign(`dacs-revocation:v1:${contentHash(markerScope)}`) } }; + const binding = (await findValidListingRevocation([locator(60)], verifiedRevoked, 1, async () => marker))!; + const record = await indexRegistration( + { primaryClaim: claim, displayName: "Interop seller", listingAnchors: [locator(1), locator(2)] }, + undefined, + offline, + ); + assert.equal(record.listings.length, 2); + record.listings = record.listings.map((listing) => listing.listingId === "interop-revoked" + ? { ...listing, status: "revoked", revocation: binding } : listing); + store.saveCatalog({ catalogVersion: "1", generatedAt: 1, sellers: [record] }); +} + +const servedPage = (page: unknown) => (async () => Response.json(page)) as typeof fetch; + +for (const [name, presentedBy] of [ + ["a plain presentedBy", claim], + ["a parameter-bearing presentedBy", `${claim}?channel=directory`], +] as const) { + test(`the pinned SDK catalog client accepts every summary the Directory serves for ${name}`, async () => { + await publishCatalog(presentedBy); + + const view = await (await sellersRoute.GET( + new NextRequest(`https://directory.test/api/dacs/sellers/${encodeURIComponent(claim)}`), + { params: Promise.resolve({ primaryClaimRef: encodeURIComponent(claim) }) }, + )).json(); + assert.deepEqual(view.listings.map((l: { status: string }) => l.status).sort(), ["active", "revoked"]); + for (const summary of view.listings) { + assert.equal(summary.seller.primaryClaim, presentedBy, "summaries carry the exact presentedBy bytes"); + } + const sellerPage = await queryListingCatalog({ + catalogUrl: "https://directory.test/api/dacs/listings", + fetchImpl: servedPage({ listings: view.listings }), + }); + assert.equal(sellerPage.status, "ok", JSON.stringify(sellerPage)); + assert.equal(sellerPage.status === "ok" && sellerPage.page.listings.length, 2); + + const listings = await (await listingsRoute.GET(new NextRequest("https://directory.test/api/dacs/listings"))).json(); + const catalogPage = await queryListingCatalog({ + catalogUrl: "https://directory.test/api/dacs/listings", + fetchImpl: servedPage(listings), + }); + assert.equal(catalogPage.status, "ok", JSON.stringify(catalogPage)); + const resolver = createCatalogBindingIndex({ + catalogUrl: "https://directory.test/api/dacs/listings", + fetchImpl: servedPage(listings), + }); + const resolution = await resolver.resolve(listingAddress(presentedBy, "interop-active", 1), owner.slice(2)); + assert.equal(resolution.status, "present", JSON.stringify(resolution)); + assert.equal(resolution.status === "present" && resolution.binding.nativeAddress, locator(1)); + + // The Directory's own lookups and links address the seller by CF-3 identity. + const filtered = await (await listingsRoute.GET(new NextRequest( + `https://directory.test/api/dacs/listings?primaryClaim=${encodeURIComponent(claim)}`, + ))).json(); + assert.deepEqual(filtered.listings.map((l: { listingId: string }) => l.listingId), ["interop-active"]); + const tiered = await (await listingsRoute.GET(new NextRequest( + "https://directory.test/api/dacs/listings?identityTier=self-declared", + ))).json(); + assert.deepEqual(tiered.listings.map((l: { listingId: string }) => l.listingId), ["interop-active"]); + for (const requested of [claim, presentedBy]) { + const params = { params: Promise.resolve({ listingId: "interop-active", version: "1" }) }; + const detail = await inspectRoute.GET(new NextRequest( + `https://directory.test/api/dacs/inspect-service/interop-active/1?seller=${encodeURIComponent(requested)}`, + ), params); + assert.equal(detail.status, 200, `inspect-service for ${requested}`); + const envelope = await detail.json(); + assert.ok(envelope.source.url.endsWith(`?seller=${encodeURIComponent(claim)}`), envelope.source.url); + const listing = await listingRoute.GET(new NextRequest( + `https://directory.test/api/dacs/listings/interop-active/1?seller=${encodeURIComponent(requested)}`, + ), { params: Promise.resolve({ listingId: "interop-active", version: "1" }) }); + assert.equal(listing.status, 200, `listing detail for ${requested}`); + assert.match(listing.headers.get("link") ?? "", new RegExp(`/service/${encodeURIComponent(claim)}/interop-active/1>`)); + } + }); +} diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index f663503..c555fc0 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -327,9 +327,16 @@ test("the indexer publishes current-SDK listings under the registered identity a record.listings.map((listing) => listing.listingId).sort(), ["depth-128", "indexed-advisory", "indexed-pay-alternative"], ); + // The seller record is keyed by the registered CF-3 identity; each §6.3.6 + // summary carries its listing's exact `presentedBy` bytes (RB-2). + assert.equal(record.primaryClaim, seller); for (const listing of record.listings) { assert.equal(listing.status, "active", listing.listingId); - assert.equal(listing.seller.primaryClaim, seller, listing.listingId); + assert.equal( + listing.seller.primaryClaim, + listing.listingId === "indexed-advisory" ? advisory : seller, + listing.listingId, + ); } } finally { globalThis.fetch = originalFetch; From 315b941647e721d7b32dae7075f9a16cd365f474 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:29:55 +0000 Subject: [PATCH 32/37] Pin the inclusion observer's history fallback and binding checks The listing inclusion observation already refused each of these inputs, but no test exercised them: - a getTxByHash miss (node2 answers `result: 400`) that falls back to newest history and finds the write on the second page, with the page size and cursor it requests; - history that ends at transaction one, and pages with no, a negative or a fractional transaction id; - history writes at another locator, with other listing bytes, or (with a browser transactionRef) an identical rewrite under another hash; - a by-hash answer carrying another transaction hash; - a block whose number is not the transaction's block, and block hashes, validator keys and validator signatures with junk prefixes or suffixes or a signature inside an array; - a native readback reporting another storage address; - a block timestamp already in milliseconds; - the observation's native-read owner, program name and value hash. Co-Authored-By: Claude Opus 5.5 --- .../test/confirm-listing.test.ts | 164 +++++++++++++++++- 1 file changed, 159 insertions(+), 5 deletions(-) diff --git a/reference-implementations/dacs-directory/test/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 7c9a8a6..5cc734f 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -138,6 +138,23 @@ const request = (body: Record) => new NextRequest( { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, ); +/** Every getTransactions request the stub served since the last stubChain. */ +const historyRequests: Array> = []; +const write = transaction.content.data[1] as Record; +/** A confirmed storage write in node history, varied from the listing's own. */ +function historyWrite(id: number, hash: string, change: { to?: string; data?: Record } = {}): NodeTransaction { + const to = change.to ?? anchorAddress; + return { + ...transaction, id, hash, to, + content: { ...transaction.content, to, data: ["storageProgram", { ...write, storageAddress: to, ...(change.data ? { data: change.data } : {}) }] }, + }; +} +/** A confirmed transfer, which is no storage write at all. */ +const transfer = (id: number | undefined): NodeTransaction => ({ + id, status: "confirmed", type: "native", hash: (id ?? 0).toString(16).padStart(64, "9"), blockNumber: 4, + content: { type: "native", from: owner, to: owner, amount: 1, data: ["native", {}] }, signature: transaction.signature, +}); + /** Stub the Demos node: native readback, canonical write, and its block. */ function stubChain(overrides: { nativeMetadata?: Record; @@ -152,12 +169,19 @@ function stubChain(overrides: { byHash?: Record; /** getNetworkInfo's `nodeVersion`; a clean build of the pinned commit by default. */ nodeVersion?: Record | null; + /** getTxByHash answers node2's unknown-hash `result: 400` for every hash. */ + byHashUnknown?: boolean; + /** getTransactions pages keyed by `start`; by default one newest page holding the write. */ + historyPages?: Record; + /** Members replacing the native readback's. */ + native?: Record; } = {}): string[] { const write = transaction.content.data[1] as Record; const written = overrides.transactionMetadata ? { ...transaction, content: { ...transaction.content, data: ["storageProgram", { ...write, metadata: overrides.transactionMetadata }] } } : transaction; const calls: string[] = []; + historyRequests.length = 0; globalThis.fetch = async (_input, init) => { if (init?.method !== "POST") { return Response.json({ @@ -167,15 +191,21 @@ function stubChain(overrides: { programName, storageAddress: anchorAddress, metadata: overrides.nativeMetadata ?? metadata, + ...overrides.native, }); } const rpc = JSON.parse(String(init.body)) as { params: Array<{ message: string; data: Record }> }; const call = rpc.params[0]!; calls.push(call.message); if (call.message === "getTransactions") { + historyRequests.push(call.data); + if (overrides.historyPages) { + return Response.json({ result: 200, response: (overrides.historyPages[String(call.data.start)] ?? []).map(historyRow) }); + } return Response.json({ result: 200, response: overrides.history === false ? [] : [historyRow(written)] }); } if (call.message === "getTxByHash") { + if (overrides.byHashUnknown) return Response.json({ result: 400, response: "error" }); return call.data.hash === transactionHash ? Response.json({ result: 200, response: { ...byHashRow(written), ...overrides.byHash } }) : Response.json({ result: 404, response: "error" }); @@ -251,6 +281,9 @@ test("a node-reported inclusion is an unauthenticated observation, never a recei // The SDK's proof hash of the getTxByHash form, whose signature is an object. assert.equal(observation.signedTransactionHash, demosSignedTransactionProofHash(byHashRow(transaction))); assert.equal(observation.nativeRead.metadataHash, sha256Hex(canonicalize(metadata))); + assert.equal(observation.nativeRead.owner, owner, "the native readback's owner"); + assert.equal(observation.nativeRead.programName, programName); + assert.equal(observation.nativeRead.valueHash, sha256Hex(canonicalize(listing))); } finally { globalThis.fetch = originalFetch; } @@ -300,6 +333,10 @@ test("listing confirmation never registers before the write is in a node-confirm { validation_data: { signatures: { "fixture-validator": "signed" } } }, { hash: "block-5" }, { hash: "" }, + // The block the node serves must be the transaction's own, under an exact hash. + { number: 6 }, + { hash: `0x${blockHash}` }, + { hash: `${blockHash}00` }, ]) { stubChain({ block }); const response = await POST(request(coordinates)); @@ -427,6 +464,11 @@ test("every validator-signature entry must have the node's key and signature sha ["a 127-hex signature", { [key]: `0x${"b".repeat(127)}` }], ["a 129-hex signature", { [key]: `0x${"b".repeat(129)}` }], ["a non-string signature", { [key]: 7 }], + ["a signature inside an array", { [key]: [signature] }], + ["a key with a junk prefix", { [`x${key}`]: signature }], + ["a key with a junk suffix", { [`${key}00`]: signature }], + ["a signature with a junk prefix", { [key]: `x${signature}` }], + ["a signature with a junk suffix", { [key]: `${signature}00` }], ] as const) { stubChain({ block: { validation_data: { signatures } } }); const response = await POST(request(coordinates)); @@ -469,13 +511,15 @@ test("native and transaction metadata must agree and bind the logical address, c test("a by-hash write at another locator or with other content never stands in for this listing", async () => { const originalFetch = globalThis.fetch; const other = `stor-${"cd".repeat(20)}`; - const write = transaction.content.data[1] as Record; + const otherHash = "ef".repeat(32); try { - for (const [name, content] of [ - ["another locator", { ...transaction.content, to: other, data: ["storageProgram", { ...write, storageAddress: other }] }], - ["other listing bytes", { ...transaction.content, data: ["storageProgram", { ...write, data: { ...listing, terms: { other: true } } }] }], + for (const [name, byHash] of [ + ["another locator", { content: { ...transaction.content, to: other, data: ["storageProgram", { ...write, storageAddress: other }] } }], + ["other listing bytes", { content: { ...transaction.content, data: ["storageProgram", { ...write, data: { ...listing, terms: { other: true } } }] } }], + ["another transaction's hash", { hash: otherHash }], ] as const) { - stubChain({ history: false, byHash: { content } }); + // The block would carry either transaction, so only the by-hash match decides. + stubChain({ history: false, byHash, block: { content: { timestamp: 1_786_360_000, ordered_transactions: [transactionHash, otherHash] } } }); const response = await POST(request({ ...coordinates, transactionRef: transactionHash })); assert.equal(response.status, 202, name); const body = await response.json() as { state: string; confirmed: unknown }; @@ -487,6 +531,116 @@ test("a by-hash write at another locator or with other content never stands in f } }); +test("a missed by-hash read falls back to newest history, paging down by transaction id", async () => { + const originalFetch = globalThis.fetch; + try { + // node2 answers an unknown hash with `result: 400`; the write is on the second page. + stubChain({ + byHashUnknown: true, + historyPages: { latest: [transfer(12), transfer(11)], 10: [historyWrite(10, transactionHash)] }, + }); + const response = await POST(request({ ...coordinates, transactionRef: transactionHash })); + assert.equal(response.status, 200); + const body = await response.json() as { state: string; nodeObservation: Observation }; + assert.equal(body.state, "finality-unverified"); + assert.equal(body.nodeObservation.transactionRef, transactionHash); + assert.deepEqual(historyRequests, [{ start: "latest", limit: 100 }, { start: 10, limit: 100 }]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("history paging stops at the oldest transaction and refuses a page without a cursor", async () => { + const originalFetch = globalThis.fetch; + try { + stubChain({ historyPages: { latest: [transfer(2), transfer(1)] } }); + const exhausted = await POST(request(coordinates)); + assert.equal(exhausted.status, 202); + assert.equal((await exhausted.json() as { reason: string }).reason, "canonical transaction is not included"); + assert.deepEqual(historyRequests, [{ start: "latest", limit: 100 }], "transaction one is the end of history"); + + for (const [name, page] of [ + ["no ids", [transfer(undefined)]], + ["a negative id", [transfer(-1)]], + ["a fractional id", [transfer(1.5)]], + ] as const) { + stubChain({ historyPages: { latest: [...page] } }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 202, name); + assert.match((await response.json() as { reason: string }).reason, /no valid cursor/, name); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a history write at another locator, with other bytes, or under another hash never stands in", async () => { + const originalFetch = globalThis.fetch; + const other = `stor-${"cd".repeat(20)}`; + const impostors = { + otherLocator: historyWrite(14, "a1".repeat(32), { to: other }), + otherBytes: historyWrite(13, "a2".repeat(32), { data: { ...listing, terms: { other: true } } }), + rewrite: historyWrite(12, "a3".repeat(32)), + }; + // The block would carry any of them, so only the history match decides. + const block = { + content: { + timestamp: 1_786_360_000, + ordered_transactions: [transactionHash, ...Object.values(impostors).map((tx) => tx.hash)], + }, + }; + try { + // Without a browser transactionRef, the newest write of these exact bytes at this locator. + stubChain({ block, historyPages: { latest: [impostors.otherLocator, impostors.otherBytes, historyWrite(10, transactionHash)] } }); + const plain = await POST(request(coordinates)); + assert.equal(plain.status, 200); + assert.equal((await plain.json() as { nodeObservation: Observation }).nodeObservation.transactionRef, transactionHash); + + // With one, an identical rewrite under another hash is not the browser's write either. + stubChain({ + block, + byHashUnknown: true, + historyPages: { latest: [impostors.otherLocator, impostors.otherBytes, impostors.rewrite, historyWrite(10, transactionHash)] }, + }); + const pinned = await POST(request({ ...coordinates, transactionRef: transactionHash })); + assert.equal(pinned.status, 200); + assert.equal((await pinned.json() as { nodeObservation: Observation }).nodeObservation.transactionRef, transactionHash); + + stubChain({ block, byHashUnknown: true, historyPages: { latest: Object.values(impostors) } }); + const none = await POST(request({ ...coordinates, transactionRef: transactionHash })); + assert.equal(none.status, 202); + assert.equal((await none.json() as { state: string }).state, "inclusion-pending"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a native readback that reports another storage address is a coordinate mismatch", async () => { + const originalFetch = globalThis.fetch; + try { + stubChain({ native: { storageAddress: `stor-${"cd".repeat(20)}` } }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 409); + const body = await response.json() as { state: string; error: string }; + assert.equal(body.state, "inclusion-failed"); + assert.equal(body.error, "native anchor coordinates do not match"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a block timestamp already in milliseconds is carried unchanged", async () => { + const originalFetch = globalThis.fetch; + try { + stubChain({ block: { content: { timestamp: 1_786_360_000_123, ordered_transactions: [transactionHash] } } }); + const response = await POST(request(coordinates)); + assert.equal(response.status, 200); + assert.equal((await response.json() as { nodeObservation: Observation }).nodeObservation.blockTimestamp, 1_786_360_000_123); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("only the pinned Demos deployment's report becomes an observation", async () => { const originalFetch = globalThis.fetch; const node2 = { ...cleanBuild, dirty: true, builtAt: "2026-10-14T16:45:24Z" }; From fcc61362754e3ba91f3650899143d9c65d8c25d8 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:29:55 +0000 Subject: [PATCH 33/37] =?UTF-8?q?Require=20the=20=C2=A76.3.5=20summary=20o?= =?UTF-8?q?n=20every=20well-known=20listing=20index=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DACS-1 §6.3.5 types every ListingIndexEntry with a `summary: { title, category, tags, priceHint? }`. The crawler checked every other entry field but accepted an entry, active or revoked, with no summary or a malformed one. It now refuses those indexes. The crawler also returns an error instead of throwing when listings.json is `null` or an array. New vectors cover the missing and malformed summaries and every other entry field (listingId, version, anchor kind, locator anchors, content hash and status), plus a non-array or oversized listing list, a null or array index and a non-string seller. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/src/catalog/wellknown.ts | 17 ++++++- .../dacs-directory/test/security.test.ts | 45 +++++++++++++++---- .../test/wellknown-crawl.test.ts | 11 ++++- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 7f2735b..8055db4 100644 --- a/reference-implementations/dacs-directory/src/catalog/wellknown.ts +++ b/reference-implementations/dacs-directory/src/catalog/wellknown.ts @@ -49,10 +49,21 @@ type ListingIndexEntry = { version?: unknown; contentHash?: unknown; anchor?: { kind?: unknown; locator?: unknown }; + summary?: unknown; status?: unknown; revocation?: unknown; }; +/** §6.3.5 ListingIndexEntry.summary: `{ title, category, tags, priceHint? }`. */ +function isListingIndexSummary(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const summary = value as Record; + return typeof summary.title === "string" && + typeof summary.category === "string" && + Array.isArray(summary.tags) && summary.tags.every((tag) => typeof tag === "string") && + (summary.priceHint === undefined || typeof summary.priceHint === "string"); +} + export type ListingIndexProjection = | { ok: true; listingAnchors: string[]; contentHashes: Record } | { ok: false; error: string }; @@ -78,6 +89,7 @@ export function projectActiveListingIndexEntries( !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) || + !isListingIndexSummary(entry.summary) || (entry.status !== "active" && entry.status !== "revoked") ) return { ok: false, error: "listings.json contains an invalid listing entry" }; @@ -163,8 +175,9 @@ export async function crawlDomain( return { domain, error: `indexHash mismatch (expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…)` }; } - const idx = index.body as ListingIndex; - if (idx.indexVersion !== "1" || !Number.isSafeInteger(idx.generatedAt) || Number(idx.generatedAt) <= 0) { + const idx = index.body && typeof index.body === "object" && !Array.isArray(index.body) + ? index.body as ListingIndex : null; + if (!idx || idx.indexVersion !== "1" || !Number.isSafeInteger(idx.generatedAt) || Number(idx.generatedAt) <= 0) { return { domain, error: "listings.json has an invalid version or generatedAt" }; } const seller = typeof idx.seller === "string" ? canonicalDemosAgentClaim(idx.seller) : null; diff --git a/reference-implementations/dacs-directory/test/security.test.ts b/reference-implementations/dacs-directory/test/security.test.ts index 89a3988..21050a2 100644 --- a/reference-implementations/dacs-directory/test/security.test.ts +++ b/reference-implementations/dacs-directory/test/security.test.ts @@ -206,6 +206,7 @@ test("well-known listing indexes enforce normative revocation coherence", () => version: 1, contentHash, anchor: { kind: "storage-program", locator: `stor-${"c".repeat(40)}` }, + summary: { title: "Service", category: "services.other", tags: ["a"] }, status: "active", }; const revocation = { @@ -227,15 +228,43 @@ test("well-known listing indexes enforce normative revocation coherence", () => { ...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); + assert.equal(projectActiveListingIndexEntries([ + { ...active, summary: { ...active.summary, priceHint: "1 DEM" } }, + ], claim).ok, true); + + const { summary: _summary, ...summaryless } = active; + for (const [name, invalid] of [ + ["an active entry carrying a binding", { ...active, revocation }], + ["a revoked entry without one", { ...active, status: "revoked" }], + ["a binding for other content", { ...active, status: "revoked", revocation: { ...revocation, listingContentHash: "f".repeat(64) } }], + ["an uppercase content hash", { ...active, contentHash: contentHash.toUpperCase() }], + ["null", null], + ["an array", [active]], + // §6.3.5 ListingIndexEntry.summary is required on every entry. + ["no summary", summaryless], + ["a revoked entry with no summary", { ...summaryless, status: "revoked", revocation }], + ["a string summary", { ...active, summary: "Service" }], + ["a summary without a title", { ...active, summary: { category: "services.other", tags: [] } }], + ["a summary without a category", { ...active, summary: { title: "Service", tags: [] } }], + ["a summary without tags", { ...active, summary: { title: "Service", category: "services.other" } }], + ["a non-string tag", { ...active, summary: { ...active.summary, tags: [1] } }], + ["a numeric priceHint", { ...active, summary: { ...active.summary, priceHint: 1 } }], + // Every other entry field. + ["no listingId", { ...active, listingId: undefined }], + ["a listingId with a slash", { ...active, listingId: "service/1" }], + ["a 129-character listingId", { ...active, listingId: "a".repeat(129) }], + ["version zero", { ...active, version: 0 }], + ["a string version", { ...active, version: "1" }], + ["another anchor kind", { ...active, anchor: { ...active.anchor, kind: "ipfs" } }], + ["a locator with a junk prefix", { ...active, anchor: { ...active.anchor, locator: `x${active.anchor.locator}` } }], + ["a locator with a junk suffix", { ...active, anchor: { ...active.anchor, locator: `${active.anchor.locator}0` } }], + ["a short content hash", { ...active, contentHash: "b".repeat(63) }], + ["another status", { ...active, status: "retired" }], + ] as const) { + assert.equal(projectActiveListingIndexEntries([invalid], claim).ok, false, name); } + assert.equal(projectActiveListingIndexEntries({ listings: [active] }, claim).ok, false, "a non-array index"); + assert.equal(projectActiveListingIndexEntries(Array(201).fill(active), claim).ok, false, "more than 200 entries"); }); test("rate-limit state ignores spoofed proxy headers by default and stays bounded", () => { diff --git a/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts b/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts index 0b0f9fb..a1c75ee 100644 --- a/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts +++ b/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts @@ -101,7 +101,8 @@ test("the crawler accepts the well-known files the Directory generates for a par test("a revocation binding is accepted only for the index seller and the address its own claim derives", () => { const entry = { listingId: "wk-revoked", version: 1, contentHash: verifiedRevoked.contentHash, - anchor: { kind: "storage-program", locator: locator(2) }, status: "revoked", revocation: binding, + anchor: { kind: "storage-program", locator: locator(2) }, + summary: { title: "wk-revoked", category: "conformance.signature", tags: [] }, status: "revoked", revocation: binding, }; assert.deepEqual(projectActiveListingIndexEntries([entry], claim), { ok: true, listingAnchors: [], contentHashes: {} }); const other = `did:demos:agent:${"b".repeat(64)}`; @@ -139,6 +140,14 @@ test("the crawler refuses well-known files whose binding, version or seller does ["fractional generatedAt", rehashed(files, JSON.stringify({ ...index, generatedAt: 1.5 })), /invalid version or generatedAt/], ["non-canonical seller", rehashed(files, JSON.stringify({ ...index, seller: claim.toUpperCase() })), /no canonical Demos seller/], ["another seller", rehashed(files, JSON.stringify({ ...index, seller: `did:demos:agent:${"b".repeat(64)}` })), /no matching revocation binding/], + ["a null index", rehashed(files, "null"), /invalid version or generatedAt/], + ["an array index", rehashed(files, JSON.stringify([index])), /invalid version or generatedAt/], + ["a non-string seller", rehashed(files, JSON.stringify({ ...index, seller: 7 })), /no canonical Demos seller/], + ["no listings array", rehashed(files, JSON.stringify({ ...index, listings: null })), /at most 200 listings/], + ["an entry without its summary", rehashed(files, JSON.stringify({ + ...index, + listings: index.listings.map(({ summary: _summary, ...entry }: Record) => entry), + })), /invalid listing entry/], ]; for (const [name, hosted, error] of cases) { const crawled = await crawlDomain(domain, served(hosted)); From e13df695d332a6662ea3e8771d7de1382a94dc73 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:29:55 +0000 Subject: [PATCH 34/37] Pin legacy anchor names, bundle signatures and storage-write status - Legacy SDK-MVP anchor names are now checked against literal strings from their producer, dacs-sdk 44d8ff2 (the SDK this Directory pinned before), including a job ID with a delimiter, which that producer left raw. The test fixture used to derive them from the same helper. The module comment names the producer. - A bundle that carries an invalid signature beside valid buyer and seller signatures, or an invalid seller signature, fails the signer policy. - Only a confirmed storageProgram transaction is a storage write candidate: a failed, pending or other-type transaction is not. Co-Authored-By: Claude Opus 5.5 --- .../src/catalog/legacySessionAnchorName.ts | 6 +++-- .../test/legacy-deal-evidence.test.ts | 11 ++++++++ .../test/scan-robustness.test.ts | 25 ++++++++++++++++++- .../dacs-directory/test/verification.test.ts | 12 +++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts index 7473ce0..68a1f36 100644 --- a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts +++ b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts @@ -1,8 +1,10 @@ /** * Historical SDK-MVP program names used only while reading explicitly * labelled legacy bundles. These strings are not a current normative SDK API. - * Preserve the raw job ID used by historical anchors, including vet names; - * the current SDK's percent-encoded producer names address different anchors. + * Preserve the raw job ID used by historical anchors, including vet names: + * the producer was dacs-sdk 44d8ff2 (`sessionAnchorName` in + * src/agent/runSessionCore.ts), the SDK this Directory pinned before. The + * current SDK's percent-encoded names address different anchors. */ export const legacySessionAnchorName = { agreement: (jobId: string): string => `dacs3:agreement:${jobId}`, diff --git a/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts index e8563d7..8c851b6 100644 --- a/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts +++ b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts @@ -155,3 +155,14 @@ test("a legacy deal with only one verifiable copy keeps its signature verdict bu assert.equal(record.deals[0].reputationEligible, false); assert.equal(record.reputation.bundleCount, 0); }); + +test("legacy anchor names are the historical producer's raw-jobId names", () => { + // Literal names from the SDK-MVP producer this Directory indexed before this + // release (dacs-sdk 44d8ff2, src/agent/runSessionCore.ts sessionAnchorName). + // It never percent-encoded the job ID, so a delimiter stays raw. + assert.equal(legacySessionAnchorName.agreement("job-1"), "dacs3:agreement:job-1"); + assert.equal(legacySessionAnchorName.evidence("job-1"), "dacs4:evidence:job-1"); + assert.equal(legacySessionAnchorName.vet("job-1"), "dacs2:verifyrecord:job-1"); + assert.equal(legacySessionAnchorName.vet("a:b"), "dacs2:verifyrecord:a:b"); + assert.equal(legacySessionAnchorName.agreement("a:b%2F"), "dacs3:agreement:a:b%2F"); +}); diff --git a/reference-implementations/dacs-directory/test/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index 9d25eea..320421e 100644 --- a/reference-implementations/dacs-directory/test/scan-robustness.test.ts +++ b/reference-implementations/dacs-directory/test/scan-robustness.test.ts @@ -14,7 +14,7 @@ const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-malformed-")); process.env.DACS_DIRECTORY_DATA = dataDirectory; process.env.DACS_SCAN_FINALITY_DEPTH = "0"; const store = await import("../src/catalog/store.js"); -const { scanChain, readStorage } = await import("../src/catalog/scan.js"); +const { scanChain, readStorage, storageWriteCandidate } = await import("../src/catalog/scan.js"); const { verifyListing } = await import("../src/catalog/listingVerification.js"); const { logicalBundleAddress, verifyBundleBinding } = await import("../src/catalog/bundleBinding.js"); const { artifactHash, buildCurrentEvidenceGraph } = await import("../src/catalog/evidenceGraph.js"); @@ -405,3 +405,26 @@ test("a SQLite failure during seller indexing fails the pass and records its sta assert.equal(scanCounts().status, "failed"); } finally { db.exec("DROP TRIGGER fail_listing_rejection"); db.close(); } }); + +test("only a confirmed storageProgram transaction is a storage write candidate", () => { + const to = `stor-${"4".repeat(40)}`; + const write = { + status: "confirmed", type: "storageProgram", blockNumber: 3, hash: "5".repeat(64), to, + content: JSON.stringify({ + type: "storageProgram", to, + data: ["storageProgram", { operation: "CREATE_STORAGE_PROGRAM", storageAddress: to, data: { listingId: "x" } }], + }), + }; + assert.deepEqual(storageWriteCandidate(write), { + locator: to, contentHash: contentHash({ listingId: "x" }), blockNumber: 3, transactionHash: "5".repeat(64), + }); + for (const [name, tx] of [ + ["a failed transaction", { ...write, status: "failed" }], + ["a pending transaction", { ...write, status: "pending" }], + ["another transaction type", { ...write, type: "native" }], + ["a failed transaction of another type", { ...write, status: "failed", type: "native" }], + ["no transaction", null], + ] as const) { + assert.equal(storageWriteCandidate(tx), null, name); + } +}); diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 27c6254..a025e17 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -267,6 +267,18 @@ test("strict bundle signer policy rejects malformed roles and binds aborts to th signatures: rawBundleSignatures(valid), signature: null, }), false); + + // Every carried signature must verify, not only those of the required roles. + const extraInvalid = result("completed", [ + { party: "buyer", verdict: "valid" }, + { party: "seller", verdict: "valid" }, + { party: "witness", verdict: "invalid" }, + ]); + assert.equal(strictSigners(extraInvalid), false, "an invalid extra signature fails the bundle"); + assert.equal(strictSigners(result("completed", [ + { party: "buyer", verdict: "valid" }, + { party: "seller", verdict: "invalid" }, + ])), false, "an invalid seller signature fails the bundle"); }); test("reputation binding requires the submitted job and seller to match the signed bundle", () => { From 3f59921a0d40ebee487d642b345e125e3672a585 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:29:55 +0000 Subject: [PATCH 35/37] Tighten the browser receipt gate and small input bounds - /register registers only after a confirm-listing answer whose anchorReceipt is `finalized` (DACS-1 LP-1). Before, it accepted any receipt object. The server issues no receipt at this pin, so nothing changes in practice. The e2e mock now models a finalized receipt and the success text says so. - build-listing answers 400 for a listingSignature that is not a string, instead of throwing. - Rejection diagnostics keep Listing IDs of up to 128 URL-safe characters, the DACS-1 Listing schema's bound, in the stored coordinates and in the public diagnostic schema. A valid 65-character ID used to lose its coordinates. - A test opens a directory.sqlite created by main's schema and checks that listing_rejections gains its coordinate columns in place and keeps its existing rows. Co-Authored-By: Claude Opus 5.5 --- .../app/api/dacs/build-listing/route.ts | 3 ++ .../dacs-directory/app/register/page.tsx | 7 +-- .../dacs-directory/e2e/register.spec.ts | 8 +-- .../dacs-directory/src/catalog/contracts.ts | 2 +- .../src/catalog/listingAdmission.ts | 3 +- .../dacs-directory/test/build-listing.test.ts | 6 +++ .../test/listing-admission.test.ts | 14 +++++ .../test/listing-rejection-upgrade.test.ts | 52 +++++++++++++++++++ 8 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/listing-rejection-upgrade.test.ts 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 e93b10f..a819dd8 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 @@ -328,6 +328,9 @@ export async function POST(req: NextRequest) { const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII let signedCandidate: Record | undefined; if (body.listingSignature !== undefined) { + if (typeof body.listingSignature !== "string") { + return NextResponse.json({ error: "listing signature must be a 64-byte Ed25519 signature" }, { status: 400 }); + } 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 }); diff --git a/reference-implementations/dacs-directory/app/register/page.tsx b/reference-implementations/dacs-directory/app/register/page.tsx index ac0c934..872c583 100644 --- a/reference-implementations/dacs-directory/app/register/page.tsx +++ b/reference-implementations/dacs-directory/app/register/page.tsx @@ -185,7 +185,8 @@ export default function Register() { }), }); const body = await response.json(); - if (response.ok && body.confirmed === true && objectValue(body.anchorReceipt)) { + // DACS-1 LP-1: only a verified `finalized` receipt lets the browser register. + if (response.ok && body.confirmed === true && objectValue(body.anchorReceipt)?.state === "finalized") { return body.anchorReceipt as Record; } if (response.status !== 202) { @@ -206,7 +207,7 @@ export default function Register() { } onStep?.("registering"); setPublishStep("registering"); - setStatus("One final wallet signature connects this included (finality not verified), authenticated catalog candidate 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" }, @@ -239,7 +240,7 @@ export default function Register() { clearPendingListingPublication(window.localStorage); setPendingPublication(null); setPublishStep("complete"); - setStatus("Your signed listing is included on-chain (finality not verified), is authenticated as a catalog candidate, and is queued for the next index pass. Buyers still run their own SDK transaction-readiness checks."); + setStatus("Your signed listing is finalized on-chain, 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"); }; diff --git a/reference-implementations/dacs-directory/e2e/register.spec.ts b/reference-implementations/dacs-directory/e2e/register.spec.ts index b31d977..8223d4f 100644 --- a/reference-implementations/dacs-directory/e2e/register.spec.ts +++ b/reference-implementations/dacs-directory/e2e/register.spec.ts @@ -88,9 +88,9 @@ test("seller publication survives registration failure and reload without rebroa confirmationPosts++; return json(route, { confirmed: true, - state: "included-and-verified", - finality: "not-verified", - anchorReceipt: { receiptVersion: "1", state: "included", transactionRef: { kind: "demos-storage-program", value: "cd".repeat(32) } }, + // The shape of a future verified receipt; the server issues none at this pin. + state: "finalized", + anchorReceipt: { receiptVersion: "1", state: "finalized", transactionRef: { kind: "demos-storage-program", value: "cd".repeat(32) } }, }); }); await context.route("**/api/dacs/prepare-registration", (route) => json(route, { @@ -146,7 +146,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(/included on-chain \(finality not verified\).*authenticated as a catalog candidate/)).toBeVisible(); + await expect(page.getByText(/finalized on-chain.*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/src/catalog/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 719c5ec..2ded1b5 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -123,7 +123,7 @@ export const listingRejectionDiagnosticSchema = { required: ["locator", "code", "message", "occurrences", "firstSeenAt", "lastSeenAt"], properties: { locator: { type: "string", pattern: "^stor-[0-9a-f]{40}$" }, - listingId: { type: "string", pattern: "^[a-z0-9-]{1,64}$" }, + listingId: { type: "string", pattern: "^[A-Za-z0-9._~-]{1,128}$" }, listingVersion: { type: "integer", minimum: 1 }, code: { enum: LISTING_REJECTION_CODES }, message: { type: "string", description: "Public-safe explanation of the failed listing admission." }, diff --git a/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts index db27a8d..fe6becb 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts @@ -27,7 +27,8 @@ export function listingDiagnosticCoordinates( ? raw.serviceId : undefined; const version = raw.listingVersion ?? raw.version ?? 1; - return listingId && /^[a-z0-9-]{1,64}$/.test(listingId) && + // DACS-1 Listing schema: URL-safe ASCII, at most 128 characters. + return listingId && /^[A-Za-z0-9._~-]{1,128}$/.test(listingId) && Number.isSafeInteger(version) && Number(version) >= 1 ? { listingId, listingVersion: Number(version) } : undefined; diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 1dfda4f..623d775 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -488,6 +488,12 @@ test("publisher returns no transaction for a listing signature that does not ver const body = await response.json() as { tx?: unknown }; assert.equal(body.tx, undefined, `${name}: no broadcastable transaction`); } + // A signature that is not a string is a client error, never an unhandled 500. + for (const listingSignature of [7, null, ["ab".repeat(64)], { value: "ab".repeat(64) }]) { + const response = await POST(request({ ...finalInput, listingSignature })); + assert.equal(response.status, 400, JSON.stringify(listingSignature)); + assert.equal((await response.json() as { tx?: unknown }).tx, undefined); + } } finally { globalThis.fetch = originalFetch; } diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index c555fc0..b251981 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -342,3 +342,17 @@ test("the indexer publishes current-SDK listings under the registered identity a globalThis.fetch = originalFetch; } }); + +test("rejection diagnostics keep every Listing ID the DACS-1 schema allows", async () => { + const { listingDiagnosticCoordinates } = await import("../src/catalog/listingAdmission.js"); + const { listingRejectionDiagnosticSchema } = await import("../src/catalog/contracts.js"); + const pattern = new RegExp(listingRejectionDiagnosticSchema.properties.listingId.pattern); + for (const listingId of ["a".repeat(65), "A.b_c~d-".repeat(16), "x"]) { + assert.deepEqual(listingDiagnosticCoordinates({ listingId, listingVersion: 2 }), { listingId, listingVersion: 2 }, listingId); + assert.ok(pattern.test(listingId), listingId); + } + for (const listingId of ["a".repeat(129), "a/b", "a b", ""]) { + assert.equal(listingDiagnosticCoordinates({ listingId, listingVersion: 2 }), undefined, listingId); + assert.equal(pattern.test(listingId), false, listingId); + } +}); diff --git a/reference-implementations/dacs-directory/test/listing-rejection-upgrade.test.ts b/reference-implementations/dacs-directory/test/listing-rejection-upgrade.test.ts new file mode 100644 index 0000000..8e54530 --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-rejection-upgrade.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +// A directory.sqlite created by main's schema has listing_rejections without +// the listing_id/listing_version diagnostic columns; the store adds them in place. +const dataDirectory = mkdtempSync(join(tmpdir(), "dacs-directory-rejection-upgrade-")); +const existingLocator = `stor-${"1".repeat(40)}`; +const existingClaim = `did:demos:agent:${"a".repeat(64)}`; +const seeded = new Database(join(dataDirectory, "directory.sqlite")); +seeded.exec(` + CREATE TABLE listing_rejections ( + locator TEXT NOT NULL, + registration_claim TEXT NOT NULL, + reason_code TEXT NOT NULL, + occurrences INTEGER NOT NULL DEFAULT 1, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + PRIMARY KEY(locator, registration_claim) + ); +`); +seeded.prepare("INSERT INTO listing_rejections VALUES (?, ?, 'OWNER_CLAIM_BINDING', 3, 1, 2)").run(existingLocator, existingClaim); +seeded.close(); +process.env.DACS_DIRECTORY_DATA = dataDirectory; +const store = await import("../src/catalog/store.js"); + +test("the store upgrades main's listing_rejections table in place and keeps its rows", () => { + const locator = `stor-${"2".repeat(40)}`; + store.recordListingRejection(locator, `did:demos:agent:${"b".repeat(64)}`, "VERIFICATION_METHOD_INVALID", { + listingId: "svc", + listingVersion: 3, + }); + const db = new Database(join(dataDirectory, "directory.sqlite"), { readonly: true }); + try { + const columns = (db.prepare("PRAGMA table_info(listing_rejections)").all() as Array<{ name: string }>) + .map((column) => column.name); + assert.ok(columns.includes("listing_id"), columns.join(",")); + assert.ok(columns.includes("listing_version"), columns.join(",")); + const rows = db.prepare( + "SELECT locator, reason_code, occurrences, listing_id, listing_version FROM listing_rejections ORDER BY locator", + ).all(); + assert.deepEqual(rows, [ + { locator: existingLocator, reason_code: "OWNER_CLAIM_BINDING", occurrences: 3, listing_id: null, listing_version: null }, + { locator, reason_code: "VERIFICATION_METHOD_INVALID", occurrences: 1, listing_id: "svc", listing_version: 3 }, + ]); + } finally { + db.close(); + } +}); From 2af03c8347bbb0b5596b2f95dbeda277ed7e39bd Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:52:21 +0200 Subject: [PATCH 36/37] Bound catalog claim queries and reuse canonical seller identities --- .../[listingId]/[version]/route.ts | 13 ++-- .../listings/[listingId]/[version]/route.ts | 13 ++-- .../app/api/dacs/listings/route.ts | 20 ++++-- .../dacs-directory/src/catalog/claimRef.ts | 11 ++-- .../dacs-directory/src/catalog/discovery.ts | 16 ++++- .../dacs-directory/src/catalog/inspection.ts | 20 +++--- .../test/catalog-query-bounds.test.ts | 66 +++++++++++++++++++ .../dacs-directory/test/claim-ref.test.ts | 15 +++++ 8 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/catalog-query-bounds.test.ts diff --git a/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts index b7ec8a6..2a80bfd 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/inspect-service/[listingId]/[version]/route.ts @@ -1,6 +1,6 @@ /** GET /api/dacs/inspect-service/{listingId}/{version} — verifier-ready Directory profile envelope. */ import { NextRequest, NextResponse } from "next/server"; -import { sellerIdentity } from "@/src/catalog/discovery"; +import { MAX_CLAIM_QUERY_BYTES, sellerIdentity } from "@/src/catalog/discovery"; import { catalogJson } from "@/src/catalog/http"; import { buildDirectoryServiceInspectionEnvelope, @@ -16,12 +16,17 @@ export async function GET( ) { const { listingId, version } = await params; const requestedSeller = req.nextUrl.searchParams.get("seller")?.trim(); + if (requestedSeller && Buffer.byteLength(requestedSeller, "utf8") > MAX_CLAIM_QUERY_BYTES) { + return NextResponse.json({ error: "seller exceeds 1024 bytes" }, { status: 400 }); + } if (!requestedSeller) { return NextResponse.json({ error: "seller is required" }, { status: 400 }); } - const hit = loadCatalog() - .sellers.flatMap((s) => s.listings) - .find((l) => l.listingId === listingId && String(l.version) === version && sellerIdentity(l.seller.primaryClaim) === sellerIdentity(requestedSeller)); + const requestedIdentity = requestedSeller ? sellerIdentity(requestedSeller) : undefined; + const hit = loadCatalog().sellers + .filter((seller) => !requestedIdentity || seller.primaryClaim === requestedIdentity) + .flatMap((seller) => seller.listings) + .find((listing) => listing.listingId === listingId && String(listing.version) === version); if (!hit) return NextResponse.json({ error: "listing not found" }, { status: 404 }); const origin = requestBaseUrl(req); diff --git a/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts b/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts index b9019b4..130b7bf 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/listings/[listingId]/[version]/route.ts @@ -6,7 +6,7 @@ import { readCounterpartyEvidenceFixtureAnchor, verifyCounterpartyEvidenceFixtureListing, } from "@/src/catalog/counterpartyEvidence"; -import { sellerIdentity } from "@/src/catalog/discovery"; +import { MAX_CLAIM_QUERY_BYTES, sellerIdentity } from "@/src/catalog/discovery"; import { verifyListing } from "@/src/catalog/listingVerification"; import { catalogJson } from "@/src/catalog/http"; import { inspectServicePath } from "@/src/catalog/inspection"; @@ -18,10 +18,15 @@ export async function GET( ) { const { listingId, version } = await params; const requestedSeller = req.nextUrl.searchParams.get("seller"); + if (requestedSeller && Buffer.byteLength(requestedSeller, "utf8") > MAX_CLAIM_QUERY_BYTES) { + return NextResponse.json({ error: "seller exceeds 1024 bytes" }, { status: 400 }); + } const origin = requestBaseUrl(req); - const hit = loadCatalog() - .sellers.flatMap((s) => s.listings) - .find((l) => l.listingId === listingId && String(l.version) === version && (!requestedSeller || sellerIdentity(l.seller.primaryClaim) === sellerIdentity(requestedSeller))); + const requestedIdentity = requestedSeller ? sellerIdentity(requestedSeller) : undefined; + const hit = loadCatalog().sellers + .filter((seller) => !requestedIdentity || seller.primaryClaim === requestedIdentity) + .flatMap((seller) => seller.listings) + .find((listing) => listing.listingId === listingId && String(listing.version) === version); if (!hit) return NextResponse.json({ error: "listing not found" }, { status: 404 }); if (hit.anchor.kind === "fixture") { const fixture = readCounterpartyEvidenceFixtureAnchor(hit.anchor.locator); diff --git a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts index 2aa412a..037eaf7 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/listings/route.ts @@ -3,7 +3,7 @@ * Normative filters plus q/profile directory extensions. */ import { NextRequest, NextResponse } from "next/server"; -import { activeCatalogListings, primaryClaimMatches, sellerIdentity } from "@/src/catalog/discovery"; +import { MAX_CLAIM_QUERY_BYTES, primaryClaimPredicate } from "@/src/catalog/discovery"; import { loadCatalog } from "@/src/catalog/store"; import { parsePagination } from "@/src/catalog/pagination"; import { catalogJson } from "@/src/catalog/http"; @@ -18,6 +18,10 @@ export async function GET(req: NextRequest) { const tags = q.getAll("tag"); const credential = q.get("credential"); const primaryClaim = q.get("primaryClaim"); + if (primaryClaim && Buffer.byteLength(primaryClaim, "utf8") > MAX_CLAIM_QUERY_BYTES) { + return NextResponse.json({ error: "primaryClaim exceeds 1024 bytes" }, { status: 400 }); + } + const matchesClaim = primaryClaim ? primaryClaimPredicate(primaryClaim) : undefined; const priceMax = q.get("priceMax"); const minCompletionRate = q.get("minCompletionRate"); const minRating = q.get("minRating"); @@ -50,14 +54,16 @@ export async function GET(req: NextRequest) { const catalog = loadCatalog(); // Discovery only advertises offers that remain active. Revoked listings stay // available on the seller history/detail surfaces with an explicit status. - const all = activeCatalogListings(catalog); - const tierByClaim = new Map(catalog.sellers.map((seller) => [seller.primaryClaim, seller.identityTier ?? "self-declared"])); - const filtered = all.filter((l) => { + const all = catalog.sellers + .filter((seller) => (!identityTier || (seller.identityTier ?? "self-declared") === identityTier) && + (!matchesClaim || matchesClaim(seller.primaryClaim))) + .flatMap((seller) => seller.listings + .filter((listing) => listing.status === "active") + .map((listing) => ({ listing, identity: seller.primaryClaim }))); + const filtered = all.filter(({ listing: l }) => { if (category && l.offering.category !== category && !l.offering.category.startsWith(category + ".")) return false; if (rail && !(l.offering.rails ?? []).includes(rail)) return false; if (profile && l.artifactProfile !== profile) return false; - if (identityTier && tierByClaim.get(sellerIdentity(l.seller.primaryClaim)) !== identityTier) return false; - if (primaryClaim && !primaryClaimMatches(l.seller.primaryClaim, primaryClaim)) return false; if (credential) { const required = Array.isArray(l.buyerRequirement?.required) ? l.buyerRequirement.required as Array> : []; @@ -88,7 +94,7 @@ export async function GET(req: NextRequest) { const origin = requestBaseUrl(req); const page = filtered .slice(cursor, cursor + limit) - .map((listing) => withDirectoryInspectionAffordance(listing)); + .map(({ listing, identity }) => withDirectoryInspectionAffordance(listing, identity)); const body = { listings: page, cursor: cursor + limit < filtered.length ? String(cursor + limit) : undefined, diff --git a/reference-implementations/dacs-directory/src/catalog/claimRef.ts b/reference-implementations/dacs-directory/src/catalog/claimRef.ts index 63527fd..52f55b9 100644 --- a/reference-implementations/dacs-directory/src/catalog/claimRef.ts +++ b/reference-implementations/dacs-directory/src/catalog/claimRef.ts @@ -50,17 +50,20 @@ function decodeParameterSegment(value: string): string | null { /** CF-2 parameters: non-empty, unique keys in code-point order. */ function canonicalParameters(query: string): boolean { if (!query) return false; - const keys: string[] = []; + const keys = new Set(); + let previous: string | undefined; for (const parameter of query.split("&")) { const equals = parameter.indexOf("="); if (equals <= 0 || equals !== parameter.lastIndexOf("=")) return false; const key = decodeParameterSegment(parameter.slice(0, equals)); - if (key === null || decodeParameterSegment(parameter.slice(equals + 1)) === null || keys.includes(key)) { + if (key === null || decodeParameterSegment(parameter.slice(equals + 1)) === null || keys.has(key)) { return false; } - keys.push(key); + if (previous !== undefined && compareCodePoints(previous, key) >= 0) return false; + keys.add(key); + previous = key; } - return keys.every((key, index) => index === 0 || compareCodePoints(keys[index - 1]!, key) < 0); + return true; } function parseDemosAgentClaim(value: string): { reference: string; identity: string } | null { diff --git a/reference-implementations/dacs-directory/src/catalog/discovery.ts b/reference-implementations/dacs-directory/src/catalog/discovery.ts index 1a482bb..ee8bb7d 100644 --- a/reference-implementations/dacs-directory/src/catalog/discovery.ts +++ b/reference-implementations/dacs-directory/src/catalog/discovery.ts @@ -21,9 +21,19 @@ export const activeCatalogSellers = (sellers: SellerRecord[]): SellerRecord[] => })) .filter((seller) => seller.listings.length > 0); +/** Bound public query work before parsing untrusted ClaimReferences. */ +export const MAX_CLAIM_QUERY_BYTES = 1024; + +/** Compile once per request; callers supply the canonical seller-record key. */ +export function primaryClaimPredicate(filter: string): (identity: string) => boolean { + const canonicalFilter = sellerIdentity(filter).toLowerCase(); + return (identity) => { + const claim = identity.toLowerCase(); + return claim === canonicalFilter || claim.startsWith(canonicalFilter + ":"); + }; +} + /** Match either one exact canonical claim or a claim-scheme/prefix filter. */ export function primaryClaimMatches(claim: string, filter: string): boolean { - const canonicalClaim = sellerIdentity(claim).toLowerCase(); - const canonicalFilter = sellerIdentity(filter).toLowerCase(); - return canonicalClaim === canonicalFilter || canonicalClaim.startsWith(canonicalFilter + ":"); + return primaryClaimPredicate(filter)(sellerIdentity(claim)); } diff --git a/reference-implementations/dacs-directory/src/catalog/inspection.ts b/reference-implementations/dacs-directory/src/catalog/inspection.ts index 47cf8a0..3a15f50 100644 --- a/reference-implementations/dacs-directory/src/catalog/inspection.ts +++ b/reference-implementations/dacs-directory/src/catalog/inspection.ts @@ -69,27 +69,27 @@ const limitations: DirectoryServiceProfile["limitations"] = [ const encode = encodeURIComponent; -export function servicePath(listing: ListingSummary): string { - return `/service/${encode(sellerIdentity(listing.seller.primaryClaim))}/${encode(listing.listingId)}/${listing.version}`; +export function servicePath(listing: ListingSummary, identity = sellerIdentity(listing.seller.primaryClaim)): string { + return `/service/${encode(identity)}/${encode(listing.listingId)}/${listing.version}`; } -export function listingJsonPath(listing: ListingSummary): string { - return `/api/dacs/listings/${encode(listing.listingId)}/${listing.version}?seller=${encode(sellerIdentity(listing.seller.primaryClaim))}`; +export function listingJsonPath(listing: ListingSummary, identity = sellerIdentity(listing.seller.primaryClaim)): string { + return `/api/dacs/listings/${encode(listing.listingId)}/${listing.version}?seller=${encode(identity)}`; } -export function inspectServicePath(listing: ListingSummary): string { - return `/api/dacs/inspect-service/${encode(listing.listingId)}/${listing.version}?seller=${encode(sellerIdentity(listing.seller.primaryClaim))}`; +export function inspectServicePath(listing: ListingSummary, identity = sellerIdentity(listing.seller.primaryClaim)): string { + return `/api/dacs/inspect-service/${encode(listing.listingId)}/${listing.version}?seller=${encode(identity)}`; } export function inspectServiceUrl(origin: string, listing: ListingSummary): string { return `${origin}${inspectServicePath(listing)}`; } -export function directoryInspectionAffordance(listing: ListingSummary): DirectoryInspectionAffordance { +export function directoryInspectionAffordance(listing: ListingSummary, identity?: string): DirectoryInspectionAffordance { return { artifactType: "directory-service-profile", maturity: "listed", - href: inspectServicePath(listing), + href: inspectServicePath(listing, identity), }; } @@ -103,10 +103,10 @@ export function withDirectoryTransactionReadiness(list return { ...listing, transactionReadiness: DIRECTORY_TRANSACTION_READINESS }; } -export function withDirectoryInspectionAffordance(listing: T): T { +export function withDirectoryInspectionAffordance(listing: T, identity?: string): T { return { ...withDirectoryTransactionReadiness(listing), - inspection: directoryInspectionAffordance(listing), + inspection: directoryInspectionAffordance(listing, identity), }; } diff --git a/reference-implementations/dacs-directory/test/catalog-query-bounds.test.ts b/reference-implementations/dacs-directory/test/catalog-query-bounds.test.ts new file mode 100644 index 0000000..e0896a5 --- /dev/null +++ b/reference-implementations/dacs-directory/test/catalog-query-bounds.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { NextRequest } from "next/server"; +import type { Catalog, ListingSummary } from "../src/catalog/types.js"; + +process.env.DACS_DIRECTORY_DATA = mkdtempSync(join(tmpdir(), "dacs-query-bounds-")); +const { saveCatalog } = await import("../src/catalog/store.js"); +const listings = await import("../app/api/dacs/listings/route.js"); +const detail = await import("../app/api/dacs/listings/[listingId]/[version]/route.js"); +const inspection = await import("../app/api/dacs/inspect-service/[listingId]/[version]/route.js"); +const claim = `did:demos:agent:${"a".repeat(64)}`; +const parameters = Array.from({ length: 1542 }, (_, i) => `k${String(i).padStart(4, "0")}=v`).join("&"); +const presentedBy = `${claim}?${parameters}`; +const listing = (i: number): ListingSummary => ({ + listingId: `svc-${i}`, version: 1, contentHash: "c".repeat(64), + anchor: { kind: "storage-program", locator: `stor-${i.toString(16).padStart(40, "0")}` }, + seller: { primaryClaim: presentedBy, displayName: "Seller" }, + offering: { title: "Service", category: "services.other", tags: [] }, + pricing: {}, status: "active", catalogObservedAt: 1, +}); +const catalog = { + catalogVersion: "1", generatedAt: 1, + sellers: [{ primaryClaim: claim, displayName: "Seller", identityTier: "verified", listings: Array.from({ length: 100 }, (_, i) => listing(i)) }], +} as Catalog; + +test("large primaryClaim and seller queries are rejected before catalog scanning", async () => { + saveCatalog(catalog); + const query = new URLSearchParams({ primaryClaim: presentedBy }); + assert.ok(query.toString().length >= 14_000); + assert.equal((await listings.GET(new NextRequest(`https://directory.test/api/dacs/listings?${query}`))).status, 400); + for (const primaryClaim of ["a".repeat(1024), "é".repeat(512)]) { + assert.equal((await listings.GET(new NextRequest(`https://directory.test/api/dacs/listings?${new URLSearchParams({ primaryClaim })}`))).status, 200); + } + const params = Promise.resolve({ listingId: "svc-0", version: "1" }); + for (const route of [detail, inspection]) { + for (const seller of ["a".repeat(1024), "é".repeat(512)]) { + const request = new NextRequest(`https://directory.test/api/dacs/listings/svc-0/1?${new URLSearchParams({ seller })}`); + assert.equal((await route.GET(request, { params })).status, 404); + } + for (const seller of [presentedBy, "a".repeat(1025), "é".repeat(513)]) { + const request = new NextRequest(`https://directory.test/api/dacs/listings/svc-0/1?${new URLSearchParams({ seller })}`); + assert.equal((await route.GET(request, { params })).status, 400); + } + } + assert.equal((await listings.GET(new NextRequest(`https://directory.test/api/dacs/listings?${new URLSearchParams({ primaryClaim: "é".repeat(513) })}`))).status, 400); +}); + +test("seller-record identity drives filters and links while summaries retain exact presentedBy", async () => { + saveCatalog(catalog); + for (const primaryClaim of [claim, `${claim}?a=b`, "did:demos:agent"]) { + const request = new NextRequest(`https://directory.test/api/dacs/listings?${new URLSearchParams({ primaryClaim, identityTier: "verified" })}`); + const response = await listings.GET(request); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.total, 100); + assert.equal(body.listings[0].seller.primaryClaim, presentedBy); + assert.equal(new URL(body.listings[0].inspection.href, "https://directory.test").searchParams.get("seller"), claim); + } + const response = await inspection.GET(new NextRequest(`https://directory.test/api/dacs/inspect-service/svc-0/1?${new URLSearchParams({ seller: `${claim}?a=b` })}`), { + params: Promise.resolve({ listingId: "svc-0", version: "1" }), + }); + assert.equal(response.status, 200); +}); diff --git a/reference-implementations/dacs-directory/test/claim-ref.test.ts b/reference-implementations/dacs-directory/test/claim-ref.test.ts index 9a67d72..2f49ea0 100644 --- a/reference-implementations/dacs-directory/test/claim-ref.test.ts +++ b/reference-implementations/dacs-directory/test/claim-ref.test.ts @@ -35,6 +35,10 @@ const CASES = [ `${claim}?a=%41`, `${claim}?a=%3a`, `${claim}?a=%3`, + `${claim}?a=%GG`, + `${claim}?a=%7F`, + `${claim}?a=\u0000`, + `${claim}?a=\u007f`, `${claim}?a=1&`, `${claim}?a=b c`, `${claim}?a=b\n`, @@ -73,3 +77,14 @@ test("advisory parameters do not change identity, resolution, or the registratio assert.equal(isCanonicalDemosAgentClaim(claim), true); assert.equal(isCanonicalDemosAgentClaim(advisory), false); }); + + +test("large canonical parameter sets remain bounded and reject late duplicates or disorder", () => { + const parameters = Array.from({ length: 60_000 }, (_, index) => `k${String(index).padStart(5, "0")}=v`); + const reference = `${claim}?${parameters.join("&")}`; + const started = performance.now(); + assert.equal(canonicalDemosAgentClaim(reference), claim); + assert.ok(performance.now() - started < 1500, "60,000 parameters must parse within a generous linear-work budget"); + assert.equal(canonicalDemosAgentClaim(`${reference}&${parameters.at(-1)}`), null); + assert.equal(canonicalDemosAgentClaim(`${reference}&a=last`), null); +}); From dd128cbf2aea5bba58625917bc228d1d00cf7c99 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:52:28 +0200 Subject: [PATCH 37/37] Preserve listing version continuity and pin legacy reconciliation gates --- .../app/api/dacs/build-listing/route.ts | 18 ++---- .../dacs-directory/e2e/register.spec.ts | 38 ++++++++++++ .../src/catalog/currentReconciliation.ts | 35 +++++++++++ .../dacs-directory/src/catalog/indexer.ts | 21 ++----- .../dacs-directory/test/build-listing.test.ts | 21 +++---- .../dacs-directory/test/chain-storage.test.ts | 24 ++++++++ .../test/legacy-reconciliation.test.ts | 61 +++++++++++++++++++ .../test/listing-admission.test.ts | 25 ++++++++ .../test/listing-verification.test.ts | 22 +++++++ 9 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 reference-implementations/dacs-directory/test/legacy-reconciliation.test.ts 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 a819dd8..97fe334 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 @@ -50,8 +50,6 @@ import { const LISTING_SEPARATOR = "dacs-listing:v1:"; const BUNDLE_SEPARATOR = "dacs-bundle-presentation:v1:"; const PUBLISHABLE_DELIVERY = new Set(["deliver-attested-payload", "deliver-storage-program", "deliver-entitlement"]); -/** Owner-held versions that no longer authenticate and may be stepped past per request. */ -const MAX_OCCUPIED_VERSIONS = 4; const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); async function accountNonce(addressHex: string): Promise { @@ -313,17 +311,9 @@ export async function POST(req: NextRequest) { verified.scope.listingId === serviceId && verified.scope.listingVersion === listingVersion, ); - // Versions are immutable. A program at this owner's name whose artifact no - // longer authenticates (for example a Listing an earlier publisher signed - // with raw hex) occupies that version permanently and leaves the catalog, so - // step past a bounded number of them instead of refusing republication. - let resolution = await resolveOwnedAnchorByName(programNameFor(listingVersion), owner); - for (let skipped = 0; skipped < MAX_OCCUPIED_VERSIONS && resolution.status === "present"; skipped += 1) { - if (recoverable(await verifyListing(resolution.record.data))) break; - listingVersion += 1; - listing.listingVersion = listingVersion; - resolution = await resolveOwnedAnchorByName(programNameFor(listingVersion), owner); - } + // An occupied but invalid version cannot establish a conforming version + // sequence. Refuse it below; recovery requires a fresh listing ID. + const resolution = await resolveOwnedAnchorByName(programNameFor(listingVersion), owner); const hash = contentHash(listing as Record); const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII let signedCandidate: Record | undefined; @@ -375,7 +365,7 @@ export async function POST(req: NextRequest) { const verified = await verifyListing(resolution.record.data); if (!verified || !recoverable(verified)) { return NextResponse.json( - { error: "the existing owner-bound program does not contain the expected authenticated listing candidate" }, + { error: "the existing owner-bound program does not contain the expected authenticated listing candidate; choose a new Service ID" }, { status: 409 }, ); } diff --git a/reference-implementations/dacs-directory/e2e/register.spec.ts b/reference-implementations/dacs-directory/e2e/register.spec.ts index 8223d4f..e4db54e 100644 --- a/reference-implementations/dacs-directory/e2e/register.spec.ts +++ b/reference-implementations/dacs-directory/e2e/register.spec.ts @@ -198,3 +198,41 @@ test("unfinished publication blocks another wallet with actionable mobile-safe g const dimensions = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, viewport: window.innerWidth })); expect(dimensions.width).toBeLessThanOrEqual(dimensions.viewport); }); + + +test("an included receipt cannot advance publication to either registration endpoint", async ({ context, page }) => { + await page.addInitScript(({ key, savedClaim, savedAnchor, savedProgram, savedHash, address }) => { + localStorage.setItem(key, JSON.stringify({ + version: 1, claim: savedClaim, listingId: "saved-service", listingVersion: 1, + anchorAddress: savedAnchor, programName: savedProgram, contentHash: savedHash, + signedListing: { dacsVersion: "1", listingId: "saved-service", listingVersion: 1, signature: {} }, + transaction: null, + registration: { primaryClaim: savedClaim, displayName: "Saved service", listingAnchors: [savedAnchor], deals: [] }, + stage: "confirming", createdAt: Date.now(), + })); + Object.assign(window, { demos: { request: async ({ method }: { method: string }) => { + if (method === "connect") return { success: true, data: { address } }; + throw new Error(`unexpected wallet method ${method}`); + } } }); + }, { key: LISTING_PUBLICATION_KEY, savedClaim: claim, savedAnchor: anchorAddress, savedProgram: programName, savedHash: contentHash, address: `0x${keyHex}` }); + let preparePosts = 0; + let registrationPosts = 0; + await context.route("**/api/dacs/confirm-listing", (route) => json(route, { + confirmed: true, state: "included", anchorReceipt: { receiptVersion: "1", state: "included" }, + })); + await context.route("**/api/dacs/prepare-registration", (route) => { + preparePosts++; + return json(route, { error: "registration must wait for finality" }, 500); + }); + await context.route("**/api/dacs/register", (route) => { + registrationPosts++; + return json(route, { error: "registration must wait for finality" }, 500); + }); + await page.goto("/register"); + await page.getByRole("button", { name: "Connect Demos wallet" }).click(); + await page.getByRole("button", { name: "Check chain and resume" }).click(); + await expect(page.locator(".progress-list li.failed")).toContainText("Confirm"); + expect(preparePosts).toBe(0); + expect(registrationPosts).toBe(0); + expect(await page.evaluate((key) => localStorage.getItem(key), LISTING_PUBLICATION_KEY)).not.toBeNull(); +}); diff --git a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts index a01e7c5..df6b0d9 100644 --- a/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts +++ b/reference-implementations/dacs-directory/src/catalog/currentReconciliation.ts @@ -95,3 +95,38 @@ export function reconcileCurrentCopies( selectedLocator, }; } + +/** Legacy references may be checked only after authenticating this exact copy. */ +export function legacyCopyCanVerifyRefs( + signaturesOk: boolean, + bindingOk: boolean, + anchoredByRole: unknown, + expectedRole: "buyer" | "seller", +): boolean { + return signaturesOk && bindingOk && anchoredByRole === expectedRole; +} + +/** Keep the two-copy and terminal-outcome gates independent of SDK reference support. */ +export function reconcileLegacyCopies( + buyerCopy: T, + sellerCopy: T | null, +) { + const divergent = Boolean( + buyerCopy.refsOk && sellerCopy?.refsOk && buyerCopy.bundle && sellerCopy.bundle && + currentBundleCopiesDiverge( + buyerCopy.bundle as unknown as Record, + sellerCopy.bundle as unknown as Record, + ), + ); + const authoritative = sellerCopy?.refsOk ? sellerCopy : buyerCopy; + const refsVerified = buyerCopy.refsOk && Boolean(sellerCopy?.refsOk) && !divergent; + const sellerOutcome = authoritative === sellerCopy + ? authoritative.bundle?.outcome : flipOutcome(authoritative.bundle?.outcome); + const currentOutcomes = new Set(["completed", "failed-perm", "failed-counterparty", "failed-substrate", "aborted-by-self", "aborted-by-other"]); + return { + authoritative, + refsVerified, + sellerOutcome, + reputationEligible: refsVerified && currentOutcomes.has(sellerOutcome ?? ""), + }; +} diff --git a/reference-implementations/dacs-directory/src/catalog/indexer.ts b/reference-implementations/dacs-directory/src/catalog/indexer.ts index 4ca39b1..b8600d3 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -53,10 +53,10 @@ import { type ListingRejectionCode, throwIfInfrastructureError, } from "./store.js"; -import { deriveSellerReputation, flipOutcome, isNeutralCancellation } from "./reputation.js"; +import { deriveSellerReputation, isNeutralCancellation } from "./reputation.js"; import { agreementPrice, buildCurrentEvidenceGraph, type EvidenceGraph } from "./evidenceGraph.js"; import { agreementRail } from "./agreementMetadata.js"; -import { currentBundleCopiesDiverge, reconcileCurrentCopies } from "./currentReconciliation.js"; +import { legacyCopyCanVerifyRefs, reconcileLegacyCopies, reconcileCurrentCopies } from "./currentReconciliation.js"; import { bundleBindingOverflowKey, resolveBundleSide, @@ -501,31 +501,22 @@ export async function indexRegistration( rawBundle, ) : false; const bindingOk = bundleMatchesRegisteredDeal(bundle, deal, reg.primaryClaim); - const refsOk = verification && signaturesOk && bindingOk && bundle?.anchoredByRole === expectedRole + const refsOk = verification && legacyCopyCanVerifyRefs(signaturesOk, bindingOk, bundle?.anchoredByRole, expectedRole) ? await refsPassStrictPolicy(verification, resolvedArtifacts) : false; return { verification, bundle, signaturesOk, refsOk, rawBundle, resolvedArtifacts }; }; const buyerCopy = await verifyCopy(deal.buyerBundleRef, "buyer"); const sellerCopy = deal.sellerBundleRef ? await verifyCopy(deal.sellerBundleRef, "seller") : null; - const divergent = Boolean( - buyerCopy.refsOk && sellerCopy?.refsOk && - buyerCopy.bundle && sellerCopy.bundle && currentBundleCopiesDiverge( - buyerCopy.bundle as unknown as Record, - sellerCopy.bundle as unknown as Record, - ), - ); - const authoritative = sellerCopy?.refsOk ? sellerCopy : buyerCopy; + const { authoritative, refsVerified: strictRefsVerified, sellerOutcome, reputationEligible } = + reconcileLegacyCopies(buyerCopy, sellerCopy); const bundle = authoritative.bundle; - const strictRefsVerified = buyerCopy.refsOk && Boolean(sellerCopy?.refsOk) && !divergent; const selectedRaw = authoritative.rawBundle as Record | null; const signedBundleScope: Record | null = selectedRaw ? Object.assign({}, selectedRaw) : null; if (signedBundleScope) { delete signedBundleScope.signatures; delete signedBundleScope.anchoredByRole; } - const sellerOutcome = authoritative === sellerCopy ? bundle?.outcome : flipOutcome(bundle?.outcome); - const currentOutcomes = new Set(["completed", "failed-perm", "failed-counterparty", "failed-substrate", "aborted-by-self", "aborted-by-other"]); const cancellation = selectedRaw?.cancellation as { claimedPolicy?: unknown } | undefined; const listingTerms = verifiedListingTerms( authoritative.verification, @@ -544,7 +535,7 @@ export async function indexRegistration( ? bundle.anchoredByRole : undefined, // Only a bundle the verifier already hashed is hashed again; a rejected raw bundle gets no hash. bundleContentHash: bundle && signedBundleScope ? contentHash(signedBundleScope) : undefined, - reputationEligible: strictRefsVerified && currentOutcomes.has(sellerOutcome ?? ""), + reputationEligible, cancellationNeutral, finalisedAt: bundle?.finalisedAt, anchorTimestamp: artifactAnchorTime(selectedLocator), diff --git a/reference-implementations/dacs-directory/test/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 623d775..3dfa13b 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -206,7 +206,7 @@ test("publisher uses PA-1 rail resolution and signs the exact staged Listing bef } }); -test("publisher steps past an owner-held version whose artifact no longer authenticates", async () => { +test("publisher refuses an invalid occupied version without skipping to the next version", async () => { const identity = await (await POST(request(base))).json() as { identityMessage: string; identityPresentedAt: number }; const walletHex = (message: string) => Buffer.from(ed25519Sign(Buffer.from(message, "utf8"), privateKey)).toString("hex"); @@ -257,17 +257,14 @@ test("publisher steps past an owner-held version whose artifact no longer authen }; assert.equal(await verifyListing(occupied.data), null); - const preview = await (await POST(request(finalInput))).json() as Built; - assert.equal(preview.exists, false); - assert.equal(preview.listing.listingVersion, 2); - assert.match(preview.logicalAddress, /:metered-ap2:v2$/); - const builtResponse = await POST(request({ ...finalInput, listingSignature: walletHex(preview.message) })); - assert.equal(builtResponse.status, 200); - const built = await builtResponse.json() as Built; - assert.equal(built.listing.listingVersion, 2); - assert.notEqual(built.programName, occupied.name); - assert.ok(built.tx, "the republished version is a new write"); - assert.ok(await verifyListing(built.listing)); + for (const input of [finalInput, { ...finalInput, listingSignature: walletHex(v1Preview.message) }]) { + const response = await POST(request(input)); + assert.equal(response.status, 409); + const body = await response.json(); + assert.match(body.error, /choose a new Service ID/); + assert.equal(body.tx, undefined, "no transaction can skip the occupied v1"); + assert.equal(body.listing, undefined, "no v2 artifact is issued for signing"); + } } finally { globalThis.fetch = originalFetch; } diff --git a/reference-implementations/dacs-directory/test/chain-storage.test.ts b/reference-implementations/dacs-directory/test/chain-storage.test.ts index 19e2d74..f7e12af 100644 --- a/reference-implementations/dacs-directory/test/chain-storage.test.ts +++ b/reference-implementations/dacs-directory/test/chain-storage.test.ts @@ -5,6 +5,7 @@ import { deriveAnchorAddress, deriveStorageAddress, LIVE_STORAGE_SALT, + readAnchorOutcome, resolveOwnedAnchorByName, } from "../src/catalog/chain.js"; @@ -114,3 +115,26 @@ test("producer resume lookup fails closed when the bounded result page is full", globalThis.fetch = originalFetch; } }); + + +test("native storage reads reject malformed locators before fetching", async () => { + const originalFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = async (input) => { + calls.push(String(input)); + return Response.json({ success: true, data: { listingId: "native" }, owner, programName }); + }; + try { + const address = `stor-${"ab".repeat(20)}`; + for (const invalid of ["", address.toUpperCase(), address.slice(0, -1), address + "0", `prefix-${address}`, `stor-${"g".repeat(40)}`, `${address}/../other`]) { + assert.deepEqual(await readAnchorOutcome(invalid), { status: "absent" }, invalid); + } + assert.equal(calls.length, 0); + const valid = await readAnchorOutcome(address); + assert.equal(valid.status, "found"); + assert.equal(calls.length, 1); + assert.ok(calls[0].endsWith(`/storage-program/${address}`)); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/legacy-reconciliation.test.ts b/reference-implementations/dacs-directory/test/legacy-reconciliation.test.ts new file mode 100644 index 0000000..c3264be --- /dev/null +++ b/reference-implementations/dacs-directory/test/legacy-reconciliation.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { legacyCopyCanVerifyRefs, reconcileLegacyCopies } from "../src/catalog/currentReconciliation.js"; +import { deriveSellerReputation } from "../src/catalog/reputation.js"; +import type { DealRecord } from "../src/catalog/types.js"; + +const copy = (refsOk = true, outcome = "completed", phaseOutcome = "ok") => ({ + refsOk, + bundle: { bundleVersion: "1", outcome, phaseSummary: [{ index: 0, kind: "commit-agreement", outcome: phaseOutcome }] }, +}); + +test("legacy reference admission requires valid signatures, deal binding, and the expected copy role", () => { + for (const expected of ["buyer", "seller"] as const) { + assert.equal(legacyCopyCanVerifyRefs(true, true, expected, expected), true); + assert.equal(legacyCopyCanVerifyRefs(false, true, expected, expected), false, "invalid signatures"); + assert.equal(legacyCopyCanVerifyRefs(true, false, expected, expected), false, "another registered deal"); + for (const role of ["buyer", "seller", "orchestrator", undefined]) { + if (role !== expected) assert.equal(legacyCopyCanVerifyRefs(true, true, role, expected), false, `unexpected role ${role}`); + } + } +}); + +test("legacy completion requires two authenticated agreeing copies and a terminal outcome", () => { + // Exercise the policy independently of the pinned SDK, which cannot yet + // authenticate legacy agreement references. Each false gate has a true peer. + const cases: Array<[string, ReturnType, ReturnType | null, boolean, boolean]> = [ + ["both valid", copy(), copy(), true, true], + ["buyer invalid", copy(false), copy(), false, false], + ["seller invalid", copy(), copy(false), false, false], + ["both invalid", copy(false), copy(false), false, false], + ["seller absent", copy(), null, false, false], + ["contradictory phase", copy(), copy(true, "completed", "fail"), false, false], + ["contradictory outcome", copy(), copy(true, "failed-substrate"), false, false], + ["nonterminal outcome", copy(true, "pending"), copy(true, "pending"), true, false], + ]; + for (const [name, buyer, seller, refsVerified, eligible] of cases) { + const result = reconcileLegacyCopies(buyer, seller); + assert.equal(result.refsVerified, refsVerified, name); + assert.equal(result.reputationEligible, eligible, name); + const reputation = deriveSellerReputation([{ + jobId: name, owners: { buyer: "buyer", seller: "seller" }, buyerBundleRef: "stor-buyer", + signatureVerified: true, ...result, anchorTimestamp: 100, verifiedAt: 100, + } as unknown as DealRecord], 0, 200); + assert.equal(reputation.bundleCount, eligible ? 1 : 0, name); + assert.equal(reputation.completed, eligible ? 1 : 0, name); + } +}); + +test("legacy authority selects the verified seller copy and reports seller-relative outcomes", () => { + const buyer = copy(true, "aborted-by-self"); + const seller = copy(true, "aborted-by-other"); + const both = reconcileLegacyCopies(buyer, seller); + assert.equal(both.authoritative, seller); + assert.equal(both.sellerOutcome, "aborted-by-other"); + assert.equal(both.refsVerified, true); + assert.equal(both.reputationEligible, true); + const one = reconcileLegacyCopies(buyer, null); + assert.equal(one.authoritative, buyer); + assert.equal(one.sellerOutcome, "aborted-by-other"); + assert.equal(one.reputationEligible, false); +}); diff --git a/reference-implementations/dacs-directory/test/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts index b251981..a625c9e 100644 --- a/reference-implementations/dacs-directory/test/listing-admission.test.ts +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -356,3 +356,28 @@ test("rejection diagnostics keep every Listing ID the DACS-1 schema allows", asy assert.equal(pattern.test(listingId), false, listingId); } }); + + +test("a declared content hash mismatch excludes a listing and a corrected declaration clears its diagnostic", async () => { + const at = `stor-${"e".repeat(40)}`; + const listing = signedListing("declared-hash", attested({ kind: "self-signed" })); + const { signature: _signature, ...scope } = listing; + const hash = contentHash(scope); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => String(input).endsWith(at) + ? Response.json({ success: true, owner: `0x${keyHex}`, programName: "opaque", data: listing }) + : new Response(null, { status: 404 }); + try { + const registration = { primaryClaim: seller, displayName: "Admission Desk", listingAnchors: [at] }; + const mismatch = await indexRegistration({ ...registration, listingContentHashes: { [at]: "f".repeat(64) } }); + assert.equal(mismatch.listings.length, 0); + assert.ok(store.indexerDiagnostics().listingRejectionDiagnostics.items.some((item) => + item.locator === at && item.code === "DECLARED_CONTENT_HASH_MISMATCH" && item.listingId === "declared-hash")); + const corrected = await indexRegistration({ ...registration, listingContentHashes: { [at]: `sha256-${hash.toUpperCase()}` } }); + assert.equal(corrected.listings.length, 1); + assert.equal(corrected.listings[0].contentHash, hash); + assert.ok(!store.indexerDiagnostics().listingRejectionDiagnostics.items.some((item) => item.locator === at)); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 76082a9..ebc4aa8 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -345,3 +345,25 @@ test("the identity presentation signs the §6.3.2 bundle_hash, which keeps a top assert.ok(await verifyListing(listingWith(identityBundleHash as never)), "SDK identityBundleHash"); assert.equal(await verifyListing(listingWith(contentHash)), null, "contentHash strips the signature member"); }); + + +test("legacy Listing admission enforces the exact 16 KiB UTF-8 boundary", async () => { + const scope = { + agentId: claim, serviceId: "legacy-size", name: "Legacy size boundary", description: "", + claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], + supportedDelivery: ["deliver-attested-payload"], + }; + const signed = (description: string) => { + const body = { ...scope, description }; + return { ...body, signature: Buffer.from(ed25519Sign(Buffer.from(`dacs-listing:v1:${contentHash(body)}`, "utf8"), privateKey)).toString("hex") }; + }; + const overhead = Buffer.byteLength(JSON.stringify(signed("")), "utf8"); + const atLimit = signed("a".repeat(16_384 - overhead)); + const overLimit = signed("a".repeat(16_385 - overhead)); + assert.equal(Buffer.byteLength(JSON.stringify(atLimit), "utf8"), 16_384); + assert.equal((await verifyListingResult(atLimit)).ok, true); + assert.deepEqual(await verifyListingResult(overLimit), { ok: false, code: "LEGACY_LISTING_INVALID" }); + const multibyte = signed("é".repeat(Math.ceil((16_385 - overhead) / 2))); + assert.ok(JSON.stringify(multibyte).length < 16_384); + assert.deepEqual(await verifyListingResult(multibyte), { ok: false, code: "LEGACY_LISTING_INVALID" }); +});