diff --git a/docs/codex-local-workflow.md b/docs/codex-local-workflow.md index 8cbe7be..67139fc 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. @@ -37,24 +38,51 @@ 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 ( 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" - 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" + 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 + 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)" ] \ || die "$sdk has local changes; inspect them, do not overwrite them" @@ -64,6 +92,9 @@ building whatever the SDK checkout happens to contain. It builds only after [ "$(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, 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 ) ``` @@ -74,34 +105,47 @@ 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, -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/README.md b/reference-implementations/dacs-directory/README.md index 454124b..463a0f4 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -6,7 +6,11 @@ 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 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. Live thesis: a Web2 marketplace *asks you to trust its database*. This directory is a @@ -20,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` 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` 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 | @@ -105,20 +109,28 @@ 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), 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 | Variable | Required | Purpose | |---|---|---| | `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`, `/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 | | `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`) | @@ -177,6 +189,42 @@ and response failures retain bounded retries. `STORAGE_NOT_FOUND` is operational diagnostic evidence only: under the current Demos mapping it is never authoritative DACS-5 absence evidence and cannot satisfy BB-8. +The same status response and the catalog UI expose `listingRejectionDiagnostics` with scope +`listing-admission`. It reports stable public-safe classes for normative shape, +verification-method, signature, identity-presentation, owner/seller binding and +declared-hash failures, including bounded listing ID/version coordinates when they can +be safely recovered and explicit republishing guidance. For example, string-valued deliverable verification methods +are excluded as `VERIFICATION_METHOD_INVALID`; the Directory never aliases them to a +registered structured verification-method variant. + +Before replacing malformed live Listings, run the read-only deployment acceptance check +against the deployed Directory and Demos RPC. The replacement manifest must map all three +known old listing IDs to their new anchors/versions and the independent verifier's supported +payload-method kinds: + +```bash +NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \ +DACS_LISTING_UPGRADE_REPLACEMENTS='[...]' \ +npm run check:listing-upgrade +``` + +It proves the actual old anchor bytes fail with `VERIFICATION_METHOD_INVALID`, their public +diagnostics carry the affected listing coordinates, each replacement passes the complete +SDK reader 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. + +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, @@ -195,7 +243,10 @@ DACS-5 absence evidence and cannot satisfy BB-8. 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. @@ -213,8 +264,12 @@ 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, WHATWG-subset `node:net` and `node:url` shims, and +base64url-patched Buffer). ## Honest limitations (MVP) @@ -226,10 +281,21 @@ client (browser: @noble-shimmed `node:crypto`, base64url-patched Buffer). `DACS_ADMIN_TOKEN` as a Bearer token. Run indexing from cron/CI, not public UI. - **Wallet publication uses three signatures**: the embedded IdentityBundle presentation, the Listing, and the catalog pointer/deal set. Registration remains catalog-side and non-normative. - Before any StorageProgram broadcast, the browser persists the public signed artifact, - exact native/write coordinates and unsigned registration. A reload re-verifies that - same anchor and refreshes only the catalog-pointer signature; it never creates another - listing version or re-sends a chain transaction automatically. + The identity signature is used to build the exact unsigned Listing shown on screen; no + broadcastable transaction is returned until the wallet signs those exact bytes. Before + any StorageProgram broadcast, the browser persists the public signed artifact, exact + native/write coordinates and unsigned registration. After broadcast, registration 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 + 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 @@ -243,14 +309,18 @@ client (browser: @noble-shimmed `node:crypto`, 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 -`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile reading, +`exercises-spec`: DACS-1 §6.3.4 current Listing publication and dual-profile catalog reading, §6.3.5 well-known generation/crawling, and §6.3.6 catalog discovery. Current artifacts -use directory-native, current-contract evidence-graph validation; the pinned SDK verifier -is retained only for labelled legacy artifacts. DACS-2 tier derivation fails closed on +pass the pinned SDK's normative shape and component-signature APIs and are explicitly +labelled transaction-readiness-unassessed until a buyer runs the complete SDK reader; current-contract +evidence graphs use directory-native validation, while the SDK bundle verifier is retained +for labelled legacy artifacts. DACS-2 tier derivation fails closed on unresolved recipe/evidence/freshness, and DACS-5 derivation includes ratings, volume, settlement uniqueness, anchor-time windowing, and deterministic receipts. Catalog computations remain advisory and independently reproducible from their refs. diff --git a/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts b/reference-implementations/dacs-directory/app/api/dacs/build-listing/route.ts index e94052f..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 @@ -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, @@ -24,16 +36,22 @@ 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, PUBLISHABLE_PRICING_KINDS, + PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + railAuthorityPermitsPublication, + RAIL_ID_PATTERN, type PublishablePricingKind, } from "@/src/catalog/listingOptions"; 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 { @@ -61,6 +79,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"; @@ -78,7 +97,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)) ) { @@ -104,7 +123,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)) { @@ -145,9 +164,31 @@ 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 final listing preimage + anchor transaction. + // and receives the exact Listing preimage. Only a third request carrying the + // Listing signature receives a broadcastable anchor transaction. const serverNow = Date.now(); const identityPresentedAt = body.identitySignature ? Number(body.identityPresentedAt) : serverNow; if (body.identitySignature && ( @@ -203,7 +244,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" @@ -214,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 = { @@ -225,7 +270,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 } : {}), @@ -234,20 +282,83 @@ 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 hash = contentHash(listing as Record); - const message = LISTING_SEPARATOR + hash; // §B.7 signing preimage, pure ASCII + 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 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 resolution = await resolveOwnedAnchorByName(programName, owner); + 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; + 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 }); + } + 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 */ } + // Wallets return hex; CORE §B.7 SIG-6 stores canonical unpadded base64url. + signedCandidate = { + ...listing, + 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 }); + } + } + + const logicalAddress = listingAddress(did, serviceId, listingVersion); + const programName = programNameFor(listingVersion); if (resolution.status === "indeterminate") { return NextResponse.json( { error: `could not safely determine whether this listing version already exists: ${resolution.reason}` }, @@ -262,24 +373,44 @@ 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 verified listing version" }, + { error: "the existing owner-bound program does not contain the expected authenticated listing candidate" }, { status: 409 }, ); } // Listing versions are immutable. Recover the already-signed artifact and // continue registration; never overwrite it with newly generated bytes. anchorAddress = resolution.address; - publishedListing = verified.listing as Record; + publishedListing = verified.listing as unknown as Record; publishedHash = verified.contentHash; } else { + // Do not prepare a broadcastable transaction until the wallet has signed + // the exact artifact returned by the preview response. + if (!signedCandidate) { + return NextResponse.json({ + listing, + message, + contentHash: hash, + artifactProfile: "dacs-v0.1", + logicalAddress, + programName, + exists: false, + tx: null, + publicationReady: false, + railResolution, + payloadCapability, + }); + } + // 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); @@ -288,8 +419,12 @@ export async function POST(req: NextRequest) { storageAddress: anchorAddress, programName, encoding: "json", - data: "__SIGNED_LISTING__", - metadata: { logicalAddress }, + data: signedCandidate, + metadata: { + logicalAddress, + contentHash: hash, + envelopeHash: sha256Hex(canonicalize(signedCandidate)), + }, acl: { mode: "public" }, salt: LIVE_STORAGE_SALT, storageLocation: "onchain", @@ -310,6 +445,7 @@ export async function POST(req: NextRequest) { status: "", blockNumber: null, }; + publishedListing = signedCandidate; } const priorRegistration = loadRegistrations().find((r) => r.primaryClaim === did); @@ -342,6 +478,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..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,12 +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 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. + * 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 { observeListingAnchorInclusion } from "@/src/catalog/listingFinality"; import { verifyListing } from "@/src/catalog/listingVerification"; import { rateLimit, rejectOversizeRequest } from "@/src/catalog/security"; @@ -26,6 +32,7 @@ export async function POST(req: NextRequest) { sellerClaim?: unknown; listingId?: unknown; listingVersion?: unknown; + transactionRef?: unknown; } | null; if ( !body || @@ -34,7 +41,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 +78,44 @@ export async function POST(req: NextRequest) { ); } + const logicalAddress = listingAddress( + body.sellerClaim, + body.listingId, + Number(body.listingVersion), + ); + const inclusion = await observeListingAnchorInclusion({ + anchorAddress: body.anchorAddress, + logicalAddress, + programName: body.programName, + listingContentHash: body.contentHash, + sellerClaim: body.sellerClaim, + transactionRef: typeof body.transactionRef === "string" + ? body.transactionRef.toLowerCase() + : undefined, + }); + if (inclusion.status === "pending") { + return NextResponse.json( + { confirmed: false, state: "inclusion-pending", reason: inclusion.reason }, + { status: 202 }, + ); + } + if (inclusion.status === "rejected") { + return NextResponse.json( + { confirmed: false, state: "inclusion-failed", error: inclusion.reason }, + { status: 409 }, + ); + } + return NextResponse.json({ - confirmed: true, - state: "verified", + 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, + nodeObservation: inclusion.observation, }); } 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/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/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/app/api/dacs/lookup/route.ts b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts index 6cc6832..46c3d8c 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/lookup/route.ts @@ -5,11 +5,10 @@ * known deals. Registration becomes "confirm what we found", not data entry. */ import { NextRequest, NextResponse } from "next/server"; -import { isListing } from "@kynesyslabs/dacs/artifacts"; -import { stripSignature } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; import { readAnchor } from "@/src/catalog/chain"; import { gcrGetIdentities } from "@/src/catalog/gcr"; +import { verifyListing } from "@/src/catalog/listingVerification"; import { loadScanState } from "@/src/catalog/store"; export async function GET(req: NextRequest) { @@ -26,9 +25,14 @@ export async function GET(req: NextRequest) { for (const [address, o] of Object.entries(state.listings)) { if (o.toLowerCase() !== owner.toLowerCase()) continue; const raw = await readAnchor(address); - const scope = raw ? stripSignature(raw) : null; - if (scope && isListing(scope)) { - listings.push({ address, title: (scope as { name?: string }).name ?? address }); + const verified = raw ? await verifyListing(raw) : null; + if (verified) { + listings.push({ + address, + title: verified.profile === "dacs-v0.1" + ? verified.listing.offering.title + : verified.listing.name, + }); } } diff --git a/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts b/reference-implementations/dacs-directory/app/api/dacs/prepare-registration/route.ts index 3f3efdc..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,6 +1,9 @@ /** * POST /api/dacs/prepare-registration — issue a fresh, content-bound owner - * signing message for an already verified listing pointer. + * signing message for a registration pointer set. It only parses and signs: + * 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/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/app/api/dacs/wellknown-files/route.ts b/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts index 65acf41..53783e7 100644 --- a/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts +++ b/reference-implementations/dacs-directory/app/api/dacs/wellknown-files/route.ts @@ -46,6 +46,7 @@ export async function GET(req: NextRequest) { ...(l.pricing.priceHint ? { priceHint: l.pricing.priceHint } : {}), }, status: l.status, + ...(l.revocation ? { revocation: l.revocation } : {}), }); } diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index bcee314..d06f3e9 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -248,6 +248,7 @@ pre.artifact { /* ── Catalog-freshness chip (home, top right) ─────────────────────────────── */ .h1-row { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.catalog-status { display: grid; justify-items: end; gap: 8px; max-width: min(100%, 720px); } .sync-chip { display: inline-flex; align-items: center; gap: 7px; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); @@ -260,6 +261,16 @@ pre.artifact { .sync-chip.warn .sync-dot { background: #d97706; } .sync-chip.warn { color: var(--text-primary); } .sync-chip.err .sync-dot { background: var(--red-strong); } +.listing-rejection-notice { + width: min(100%, 720px); padding: 10px 12px; border: 1px solid var(--amber-border, #f3c67a); + border-radius: var(--radius-card); background: var(--amber-soft, #fff8e8); color: var(--text-primary); + font-size: .78rem; +} +.listing-rejection-notice summary { cursor: pointer; font-weight: 700; } +.listing-rejection-notice p { margin: 8px 0; color: var(--text-secondary); } +.listing-rejection-notice ul { display: grid; gap: 8px; margin: 8px 0 0; padding-left: 18px; } +.listing-rejection-notice li span { display: block; color: var(--text-secondary); margin: 2px 0; } +.listing-rejection-notice code { font-size: .68rem; } .sync-dot.pulse { animation: sync-pulse 1.1s ease-in-out infinite; } @keyframes sync-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } .sync-refresh { diff --git a/reference-implementations/dacs-directory/app/how-it-works/page.tsx b/reference-implementations/dacs-directory/app/how-it-works/page.tsx index 2f0c7f7..ad0fcac 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" }, @@ -95,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/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..872c583 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"); @@ -52,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); @@ -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,13 +163,13 @@ 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…"); 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", @@ -144,10 +181,14 @@ 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; + // 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) { throw new Error(body.error ?? "The anchored listing failed independent verification."); } @@ -166,7 +207,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 +240,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 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"); }; @@ -208,8 +249,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 +276,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 +301,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 +316,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 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 +399,23 @@ export default function Register() { } if (!publication.exists) { + 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); 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 +506,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 +519,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 +528,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 +543,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..8223d4f 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,12 @@ 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, + // 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, { registration: { @@ -94,7 +116,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 +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(/anchored, independently verified, and queued/)).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/next.config.mjs b/reference-implementations/dacs-directory/next.config.mjs index 5e12040..f927f70 100644 --- a/reference-implementations/dacs-directory/next.config.mjs +++ b/reference-implementations/dacs-directory/next.config.mjs @@ -23,6 +23,23 @@ 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; + }), + ); + // 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/package-lock.json b/reference-implementations/dacs-directory/package-lock.json index ffb52af..29968c0 100644 --- a/reference-implementations/dacs-directory/package-lock.json +++ b/reference-implementations/dacs-directory/package-lock.json @@ -1862,27 +1862,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "vendor/dacs-sdk": { - "name": "@kynesyslabs/dacs", - "version": "0.1.0-alpha.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@kynesyslabs/demosdk": "^4.0.12", - "@x402/core": "^2.15.0", - "@x402/evm": "^2.15.0", - "@x402/fetch": "^2.15.0", - "viem": "^2.52.2" - }, - "devDependencies": { - "@types/node": "^20", - "typescript": "^5", - "vitest": "^4.0.18" - }, - "engines": { - "node": ">=20" - } } } } diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..7b3d101 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -12,6 +12,7 @@ "start:railway": "bash scripts/start-railway.sh", "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", + "check:listing-upgrade": "tsx scripts/check-live-listing-upgrade.ts", "test": "tsx --test test/*.test.ts test/*.test.mjs", "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", diff --git a/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts new file mode 100644 index 0000000..503aeb0 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-live-listing-upgrade.ts @@ -0,0 +1,143 @@ +/** + * Read-only deployment acceptance for replacing the three malformed live x402 + * Listings. This never writes, reindexes, purchases, or invokes an agent. + * + * Required: + * NEXT_PUBLIC_DIRECTORY_URL=https://... + * DACS_LISTING_UPGRADE_REPLACEMENTS='[{"listingId":"...","replacementLocator":"stor-...","replacementVersion":4,"supportedPayloadMethods":["self-signed"]}, ...]' + */ +import invalidLiveListings from "../test/fixtures/live-invalid-verification-methods.json"; +import { validateListingArtifact } from "../src/sdkListingValidation.js"; +import { verifyListingResult } from "../src/catalog/listingVerification.js"; +import { listingUpgradeReaderDeps, listingUpgradeRefusal } from "../src/catalog/listingUpgradeAcceptance.js"; + +type Replacement = { + listingId: string; + replacementLocator: string; + replacementVersion: number; + supportedPayloadMethods: string[]; +}; + +const RPC = (process.env.DEMOS_RPC ?? "https://demosnode.discus.sh/").replace(/\/$/, ""); + +function httpsOrigin(name: string): string { + const raw = process.env[name]?.trim(); + if (!raw) throw new Error(`${name} is required`); + const url = new URL(raw); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !["", "/"].includes(url.pathname)) { + throw new Error(`${name} must be an HTTPS origin`); + } + return url.origin; +} + +function replacements(): Replacement[] { + const raw = process.env.DACS_LISTING_UPGRADE_REPLACEMENTS; + if (!raw) throw new Error("DACS_LISTING_UPGRADE_REPLACEMENTS is required"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed) || parsed.length !== invalidLiveListings.length) { + throw new Error(`replacement manifest must contain exactly ${invalidLiveListings.length} entries`); + } + const allowedKeys = new Set(["listingId", "replacementLocator", "replacementVersion", "supportedPayloadMethods"]); + for (const item of parsed) { + if ( + !item || typeof item !== "object" || Array.isArray(item) || + Object.keys(item).some((key) => !allowedKeys.has(key)) || + typeof item.listingId !== "string" || + typeof item.replacementLocator !== "string" || !/^stor-[0-9a-f]{40}$/.test(item.replacementLocator) || + !Number.isSafeInteger(item.replacementVersion) || Number(item.replacementVersion) < 1 || + !Array.isArray(item.supportedPayloadMethods) || !item.supportedPayloadMethods.every((kind: unknown) => typeof kind === "string") + ) throw new Error("replacement manifest contains an invalid entry"); + } + const typed = parsed as Replacement[]; + for (const old of invalidLiveListings) { + const replacement = typed.find((candidate) => candidate.listingId === old.listingId); + if (!replacement) { + throw new Error(`replacement manifest is missing ${old.listingId}`); + } + if (replacement.replacementVersion <= old.listingVersion) { + throw new Error(`${old.listingId} replacement version must be newer than v${old.listingVersion}`); + } + } + return typed; +} + +async function json(url: string): Promise> { + const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); + const body = await response.json() as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error(`${url} returned a non-object`); + return body as Record; +} + +async function anchor(locator: string): Promise> { + const body = await json(`${RPC}/storage-program/${locator}`); + if (body.success !== true || !body.data || typeof body.data !== "object" || Array.isArray(body.data)) { + throw new Error(`${locator} is not a public Listing anchor`); + } + return body.data as Record; +} + +async function main() { + const directory = httpsOrigin("NEXT_PUBLIC_DIRECTORY_URL"); + const manifest = replacements(); + + for (const old of invalidLiveListings) { + const raw = await anchor(old.locator); + const result = await verifyListingResult(raw); + if (result.ok || result.code !== "VERIFICATION_METHOD_INVALID") { + throw new Error(`${old.listingId}@${old.listingVersion} did not fail as VERIFICATION_METHOD_INVALID`); + } + const diagnostics = await json(`${directory}/api/dacs/status?locator=${encodeURIComponent(old.locator)}&deadLetterLimit=10`); + const indexer = diagnostics.indexer as Record | undefined; + const listingDiagnostics = indexer?.listingRejectionDiagnostics as Record | undefined; + const items = Array.isArray(listingDiagnostics?.items) ? listingDiagnostics.items : []; + if (!items.some((item) => item && typeof item === "object" && !Array.isArray(item) && + item.code === "VERIFICATION_METHOD_INVALID" && item.listingId === old.listingId && item.listingVersion === old.listingVersion)) { + throw new Error(`${old.listingId} is missing its expected public rejection diagnostic`); + } + + const replacement = manifest.find((candidate) => candidate.listingId === old.listingId)!; + const replacementRaw = await anchor(replacement.replacementLocator); + const authenticated = await verifyListingResult(replacementRaw); + if (!authenticated.ok || authenticated.value.profile !== "dacs-v0.1") { + throw new Error(`${replacement.listingId}@${replacement.replacementVersion} failed catalog authentication`); + } + const listing = authenticated.value.listing; + const oldSeller = (raw.seller as Record | undefined)?.identity as Record | undefined; + if ( + listing.listingId !== replacement.listingId || + listing.listingVersion !== replacement.replacementVersion || + listing.seller.identity.presentedBy !== oldSeller?.presentedBy + ) { + throw new Error(`${replacement.listingId} replacement tuple does not match its manifest and prior seller`); + } + + const catalog = await json(`${directory}/api/dacs/listings?primaryClaim=${encodeURIComponent(authenticated.value.sellerClaim)}&limit=100`); + const summaries = Array.isArray(catalog.listings) ? catalog.listings : []; + const summary = summaries.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && + candidate.listingId === replacement.listingId && candidate.version === replacement.replacementVersion) as Record | undefined; + if ( + !summary || + (summary.anchor as Record | undefined)?.locator !== replacement.replacementLocator || + summary.contentHash !== authenticated.value.contentHash || + (summary.transactionReadiness as Record | undefined)?.disposition !== "unassessed" + ) throw new Error(`${replacement.listingId} replacement is not exposed by the reindexed catalog`); + + const validation = await validateListingArtifact(replacementRaw, 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`); + } + process.stdout.write("ok every replacement is independently SDK-verified and discoverable\n"); +} + +main().catch((error) => { + process.stderr.write(`[listing upgrade acceptance] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs new file mode 100644 index 0000000..e93f19d --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/sdk-dist-digest.mjs @@ -0,0 +1,49 @@ +#!/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, realpathSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +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"); + +// 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 ]"); + 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 ef9bdeb..4d5c4be 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="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 @@ -23,18 +23,65 @@ 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" +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 + 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 +} + +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 - echo "using packaged dacs-sdk build" + # 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 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") - (cd vendor/dacs-sdk && npm install --no-audit --no-fund && npm run build) + 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 + # 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" 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 20fb467..1d9f919 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 && @@ -134,7 +136,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 && @@ -290,7 +293,7 @@ export function verifiedListingTerms( } export function bundleCategory( - bundle: AttestationBundle | undefined, + bundle: { listingRef: { listingId: string } } | undefined, categoriesByListing: Map, ): string | undefined { return bundle ? categoriesByListing.get(String(bundle.listingRef.listingId)) : undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/chain.ts b/reference-implementations/dacs-directory/src/catalog/chain.ts index 181d851..39bcbcf 100644 --- a/reference-implementations/dacs-directory/src/catalog/chain.ts +++ b/reference-implementations/dacs-directory/src/catalog/chain.ts @@ -39,24 +39,70 @@ 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 { - 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 for reads whose absence would decide a verdict. + * + * DEMOS-MAPPING §A.2 gives an ordinary storage API `not found` the CORE SR-2 + * disposition `indeterminate`, and DACS-1 RB-6 makes a transport failure or + * integrity mismatch `indeterminate` as well. Every read that does not yield a + * readable artifact is therefore `unavailable`: a 404, any other non-2xx, a + * failed or oversized body, or a response that holds no artifact object. Only + * an address that can never name a StorageProgram is `absent`. + */ +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" }; + } + const record = await anchorRecordFromResponse(res); + return record ? { status: "found", record } : { status: "unavailable" }; +} + +async function anchorRecordFromResponse(res: Response): Promise { + try { if (!res.ok) return null; const json = (await storageResponseJson(res)) as { success?: boolean; data?: Record; 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) && boundedJson(json.data) !== null - ? { 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; @@ -159,12 +205,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) && boundedJson(body.data) !== null && 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/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/contracts.ts b/reference-implementations/dacs-directory/src/catalog/contracts.ts index 4b6d057..2ded1b5 100644 --- a/reference-implementations/dacs-directory/src/catalog/contracts.ts +++ b/reference-implementations/dacs-directory/src/catalog/contracts.ts @@ -1,4 +1,5 @@ import { publicDemosRpcUrl } from "./substrateDiscovery.js"; +import { LISTING_REJECTION_CODES } from "./listingAdmission.js"; export const artifactProfiles = ["dacs-v0.1", "legacy-sdk-v0.1", "fixture-listing"] as const; @@ -7,7 +8,7 @@ export const listingSummarySchema = { $id: "/schemas/listing-summary.schema.json", title: "DACS Directory ListingSummary", type: "object", - required: ["listingId", "version", "contentHash", "anchor", "seller", "offering", "pricing", "status", "catalogObservedAt"], + required: ["listingId", "version", "contentHash", "anchor", "seller", "offering", "pricing", "status", "catalogObservedAt", "transactionReadiness"], properties: { listingId: { type: "string", minLength: 1 }, version: { type: "integer", minimum: 1 }, @@ -49,7 +50,7 @@ export const listingSummarySchema = { }, }, status: { enum: ["active", "revoked"] }, - revocationBinding: { + revocation: { type: "object", required: [ "sellerPrimaryClaim", "listingId", "listingVersion", "listingContentHash", @@ -70,6 +71,16 @@ export const listingSummarySchema = { }, }, catalogObservedAt: { type: "integer" }, + transactionReadiness: { + type: "object", + required: ["disposition", "reason"], + additionalProperties: false, + properties: { + disposition: { const: "unassessed" }, + reason: { type: "string", minLength: 1 }, + }, + description: "Catalog admission is not buyer/session admission. Run the current SDK Listing reader with buyer-local dependencies before transacting.", + }, reputationHint: { type: "object" }, inspection: { type: "object", @@ -84,8 +95,8 @@ export const listingSummarySchema = { }, allOf: [{ if: { properties: { status: { const: "revoked" } }, required: ["status"] }, - then: { required: ["revocationBinding"] }, - else: { not: { required: ["revocationBinding"] } }, + then: { required: ["revocation"] }, + else: { not: { required: ["revocation"] } }, }], } as const; @@ -112,8 +123,10 @@ export const listingRejectionDiagnosticSchema = { required: ["locator", "code", "message", "occurrences", "firstSeenAt", "lastSeenAt"], properties: { locator: { type: "string", pattern: "^stor-[0-9a-f]{40}$" }, - code: { enum: ["SELLER_CLAIM_BINDING", "OWNER_CLAIM_BINDING"] }, - message: { type: "string", description: "Public-safe explanation of the failed listing binding." }, + listingId: { type: "string", pattern: "^[A-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." }, occurrences: { type: "integer", minimum: 1 }, firstSeenAt: { type: "integer", minimum: 0 }, lastSeenAt: { type: "integer", minimum: 0 }, @@ -190,7 +203,7 @@ export const catalogStatusSchema = { type: "object", required: ["scope", "total", "byCode", "query", "returned", "hasMore", "items"], properties: { - scope: { const: "listing-registration-binding" }, + scope: { const: "listing-admission" }, total: { type: "integer", minimum: 0 }, byCode: { type: "object", additionalProperties: { type: "integer", minimum: 0 } }, query: { diff --git a/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts b/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts index 8ee0d43..39789f5 100644 --- a/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts +++ b/reference-implementations/dacs-directory/src/catalog/counterpartyEvidence.ts @@ -2,6 +2,7 @@ import { createHash, createPublicKey, verify as verifySignature } from "node:cry import fixtureReceipt from "../../data/counterparty-evidence/microsoft-counterparty.receipt.json"; import type { SellerRecord } from "./types.js"; +import { DIRECTORY_TRANSACTION_READINESS } from "./inspection.js"; type JsonValue = | null @@ -181,6 +182,7 @@ export function counterpartyEvidenceSellerRecord(now = Date.now()): SellerRecord pricing: {}, status: "active", catalogObservedAt: now, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, }], deals: [], reputation: { diff --git a/reference-implementations/dacs-directory/src/catalog/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 1b3a6c2..4ca39b1 100644 --- a/reference-implementations/dacs-directory/src/catalog/indexer.ts +++ b/reference-implementations/dacs-directory/src/catalog/indexer.ts @@ -19,24 +19,24 @@ import { ownArray } from "./scanState.js"; import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { contentHash } from "@kynesyslabs/dacs/canonical"; import { parseCciRecord } from "@kynesyslabs/dacs/identity"; +import { isLegacyMvpAttestationBundle } from "@kynesyslabs/dacs/artifacts"; // verifyBundleCore is exported from the SDK's agent barrel, but that barrel // also re-exports createAgent/Agent.js, which pulls in the substrate/bridge // dependency chain (rubic-sdk → cetus-sui-clmm-sdk → a broken @mysten/bcs // 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"; -// The SDK doesn't export sessionAnchorName from its public barrel -// (dacs-sdk#178, follow-up to #14) — reach into the vendored build. -import { sessionAnchorName } from "../../vendor/dacs-sdk/dist/agent/runSessionCore.js"; -import { readAnchor, readAnchorRecord } from "./chain.js"; +import { readAnchor, readAnchorOutcome, readAnchorRecord } from "./chain.js"; import { gcrGetIdentities } from "./gcr.js"; import { findValidListingRevocation, ownerClaim, REVOCATION_VERIFICATIONS_PER_PASS, - verifyListing, + verifyListingResult, type RevocationCandidateProgress, } 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"; @@ -44,6 +44,7 @@ import { verifyOwnerSignature } from "./registrationSig.js"; import { artifactAnchorTime, clearListingRejection, + isOwnerRevocationCandidate, nextRevocationCandidates, resolveProgramAddress, loadScanState, @@ -62,6 +63,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, @@ -80,6 +82,7 @@ import type { ListingSummary, Registration, RegisteredDeal, + RevocationBinding, SellerRecord, } from "./types.js"; @@ -105,6 +108,30 @@ 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; +} + +/** 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. * @@ -137,7 +164,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, @@ -155,9 +182,18 @@ export async function indexRegistration( for (const anchor of reg.listingAnchors) { const anchored = await readAnchorRecord(anchor); if (!anchored) continue; - // A listing with no canonical form throws while hashing; skip it like any invalid one. - const verified = await verifyListing(anchored.data).catch(() => null); - if (!verified) continue; + // Fails closed: a listing with no canonical form is rejected like any invalid one. + const verification = await verifyListingResult(anchored.data); + if (!verification.ok) { + recordListingRejection( + anchor, + reg.primaryClaim, + verification.code, + listingDiagnosticCoordinates(anchored.data), + ); + continue; + } + const verified = verification.value; const { scope } = verified; const bindingRejection = listingBindingRejection( verified.sellerClaim, @@ -165,12 +201,25 @@ export async function indexRegistration( reg.primaryClaim, ); if (bindingRejection) { - recordListingRejection(anchor, reg.primaryClaim, bindingRejection); + recordListingRejection( + anchor, + reg.primaryClaim, + bindingRejection, + listingDiagnosticCoordinates(verified.scope), + ); continue; } - clearListingRejection(anchor, reg.primaryClaim); const declaredHash = reg.listingContentHashes?.[anchor]?.replace(/^sha256-/, "").toLowerCase(); - if (declaredHash && declaredHash !== verified.contentHash) continue; + if (declaredHash && declaredHash !== verified.contentHash) { + recordListingRejection( + anchor, + reg.primaryClaim, + "DECLARED_CONTENT_HASH_MISMATCH", + listingDiagnosticCoordinates(verified.scope), + ); + continue; + } + clearListingRejection(anchor, reg.primaryClaim); const listingId = typeof scope.listingId === "string" ? scope.listingId : typeof scope.serviceId === "string" ? scope.serviceId : ""; if (!listingId) continue; @@ -178,26 +227,40 @@ 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; // Verification work is bounded per pass: markers already verified come first (even when // an older snapshot omitted them from the queue), then the next pending candidates, // those anchored by the listing's own owner first. Only one batch is ever selected. const limit = REVOCATION_VERIFICATIONS_PER_PASS; const progress: RevocationCandidateProgress = { unread: [], rejected: [] }; - const revocationBinding = await findValidListingRevocation( + 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 (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. + 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; @@ -210,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: { @@ -230,9 +300,10 @@ export async function indexRegistration( ? scope.buyerRequirement as Record : undefined, terms: scope.terms && typeof scope.terms === "object" ? scope.terms as Record : undefined, - status: revocationBinding ? "revoked" : "active", - ...(revocationBinding ? { revocationBinding } : {}), + status: revocation ? "revoked" : "active", + ...(revocation ? { revocation } : {}), catalogObservedAt: now, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, }); listingArtifacts.set(`${listingId}\n${version}\n${verified.contentHash}`, { locator: anchor, raw: anchored.data }); } @@ -408,9 +479,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 = resolveProgramAddress(deal.owners.buyer, name); if (!address) return null; @@ -422,7 +493,9 @@ export async function indexRegistration( (await resolveDemosPrimaryClaimKey(claim, "ed25519"))?.publicKey ?? null, verify, }).catch(() => null); - const bundle = verification?.bundle; + const bundle = verification && isLegacyMvpAttestationBundle(verification.bundle) + ? verification.bundle + : undefined; const signaturesOk = verification ? hasRequiredBundleSignatures( verification, rawBundle, diff --git a/reference-implementations/dacs-directory/src/catalog/inspection.ts b/reference-implementations/dacs-directory/src/catalog/inspection.ts index 384ddb2..47cf8a0 100644 --- a/reference-implementations/dacs-directory/src/catalog/inspection.ts +++ b/reference-implementations/dacs-directory/src/catalog/inspection.ts @@ -1,8 +1,15 @@ import type { DirectoryInspectionAffordance, DirectoryServiceMaturity, + DirectoryTransactionReadiness, ListingSummary, } from "./types.js"; +import { sellerIdentity } from "./discovery.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"; @@ -22,6 +29,7 @@ export type DirectoryServiceProfile = { noLivePaymentClaim: true; reason: string; }; + transactionReadiness: DirectoryTransactionReadiness; service: { title: string; category: string; @@ -62,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 { @@ -85,9 +93,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, + ...withDirectoryTransactionReadiness(listing), inspection: directoryInspectionAffordance(listing), }; } @@ -109,8 +127,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: DIRECTORY_TRANSACTION_READINESS, service: { title: listing.offering.title, category: listing.offering.category, diff --git a/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts new file mode 100644 index 0000000..68a1f36 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/legacySessionAnchorName.ts @@ -0,0 +1,13 @@ +/** + * 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 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}`, + evidence: (jobId: string): string => `dacs4:evidence:${jobId}`, + vet: (jobId: string): string => `dacs2:verifyrecord:${jobId}`, +}; diff --git a/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts new file mode 100644 index 0000000..fe6becb --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingAdmission.ts @@ -0,0 +1,46 @@ +/** Stable, public-safe listing admission diagnostics. */ +export const LISTING_REJECTION_CODES = [ + "SELLER_CLAIM_BINDING", + "OWNER_CLAIM_BINDING", + "NORMATIVE_LISTING_INVALID", + "VERIFICATION_METHOD_INVALID", + "LISTING_SIGNATURE_INVALID", + "IDENTITY_PRESENTATION_INVALID", + "LEGACY_LISTING_INVALID", + "DECLARED_CONTENT_HASH_MISMATCH", +] as const; + +export type ListingRejectionCode = typeof LISTING_REJECTION_CODES[number]; + +export interface ListingDiagnosticCoordinates { + listingId: string; + listingVersion: number; +} + +/** Recover only bounded, public-safe coordinates from an otherwise rejected artifact. */ +export function listingDiagnosticCoordinates( + raw: Record, +): ListingDiagnosticCoordinates | undefined { + const listingId = typeof raw.listingId === "string" + ? raw.listingId + : typeof raw.serviceId === "string" + ? raw.serviceId + : undefined; + const version = raw.listingVersion ?? raw.version ?? 1; + // 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; +} + +export const LISTING_REJECTION_MESSAGES: Record = { + SELLER_CLAIM_BINDING: "The authenticated listing candidate seller does not match the registration claim.", + OWNER_CLAIM_BINDING: "The listing anchor owner does not match the registration claim.", + NORMATIVE_LISTING_INVALID: "The current listing does not satisfy the pinned SDK's normative Listing validator.", + VERIFICATION_METHOD_INVALID: "The listing deliverable verification method is missing or is not a registered structured variant.", + LISTING_SIGNATURE_INVALID: "The listing signature is malformed, unsupported, unresolved, or cryptographically invalid.", + IDENTITY_PRESENTATION_INVALID: "The listing seller identity presentation could not be authenticated.", + LEGACY_LISTING_INVALID: "The artifact does not satisfy the SDK's explicit legacy Listing read profile.", + DECLARED_CONTENT_HASH_MISMATCH: "The discovery channel's declared listing content hash does not match the verified artifact.", +}; diff --git a/reference-implementations/dacs-directory/src/catalog/listingFinality.ts b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts new file mode 100644 index 0000000..6ac10d5 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/listingFinality.ts @@ -0,0 +1,291 @@ +import { canonicalize, contentHash, sha256Hex } from "@kynesyslabs/dacs/canonical"; +import { demosSignedTransactionProofHash } from "../sdkDemosWriteEvidence.js"; + +import { readAnchorRecord } from "./chain.js"; +import { nodeBuildRejection, pinnedChainIdentity, reportedNodeBuild } from "./nodeDeployment.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; +} + +/** + * 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 }).catch(() => null)); + const genesisHash = typeof genesis?.hash === "string" ? genesis.hash.trim().toLowerCase() : ""; + 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 || + 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) { + 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 normalizedTransaction(tx); + } + } + 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`); +} + +/** + * 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 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 }; +} + +/** + * 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 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 }; + +/** + * 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 observeListingAnchorInclusion(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 included" }; + 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" || !/^[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 confirmed by the configured node" }; + + // 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 nativeValueHash = sha256Hex(canonicalize(anchored.data)); + 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" }; + return { + status: "included", + authenticated: false, + observation: { + chainIdentity: deployment.chainIdentity, + nodeBuild: deployment.build, + logicalAddress: input.logicalAddress, + nativeAddress: input.anchorAddress, + contentHash: input.listingContentHash, + writer: input.sellerClaim, + 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)), + }, + }, + }; + } catch (error) { + return { + status: "pending", + reason: error instanceof Error ? error.message : "inclusion observation failed", + }; + } +} + +/** + * 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, which is why no receipt is issued. + */ +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/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 1d66fb0..b3bee7a 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingOptions.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingOptions.ts @@ -1,17 +1,55 @@ +/** + * 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", phaseKind: "pay-dem", label: "DEM on Demos", availability: "live" }, - { railId: "pay-x402", 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, phaseKind: "pay-ap2", label: "Stripe PaymentIntents via AP2", availability: "operator_gated", }, ] as const; +/** + * DACS-4 PA-1 definitions carried in this exact reviewed app release. + * + * 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: false, +})); + +/** + * 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 }): boolean { + return result.disposition === "verified"; +} + 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/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/src/catalog/listingVerification.ts b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts index 5863ac6..4375959 100644 --- a/reference-implementations/dacs-directory/src/catalog/listingVerification.ts +++ b/reference-implementations/dacs-directory/src/catalog/listingVerification.ts @@ -1,12 +1,23 @@ import { contentHash, stripSignature } from "@kynesyslabs/dacs/canonical"; -import { isListing, type Listing } from "@kynesyslabs/dacs/artifacts"; -import { safePublicEndpoint } from "./publicEndpoint.js"; +import { + ARTIFACT_SEPARATORS, + isCanonicalBase64Url, + isListing, + isVerificationMethod, + readListingArtifact, + verifyComponentSignature, + type IdentityBundle, + type LegacyMvpListing, + type Listing, +} from "@kynesyslabs/dacs/artifacts"; +import { identityBundleHash } from "@kynesyslabs/dacs/identity"; import { canonicalSigningIdentity, resolvePrimaryClaimKey, resolveDemosPrimaryClaimKey, sameResolvedPrimaryClaim, verifyPrimaryClaimSignature, + verifyResolvedPrimaryClaimSignature, type ResolvedPrimaryClaimKey, type ResolvePrimaryClaimKey, } from "./primaryClaimKey.js"; @@ -14,6 +25,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")); @@ -25,100 +42,59 @@ function decodeSignature(value: string): Uint8Array | null { } } -export interface VerifiedListing { - listing: Listing | Record; +/** + * 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; 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); - 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 }; +function invalid( + code: ListingVerificationFailureCode, +): ListingVerificationResult { + return { ok: false, code }; } async function verifyEd25519( @@ -144,47 +120,113 @@ 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); } -/** 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. + * Fails closed: an artifact that cannot be canonicalized or hashed (for + * example a member with no canonical form, such as JSON 1e400) is invalid. + */ +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 { + try { + return await verifyListingArtifact(raw, resolveKey); + } catch { + return invalid(raw.dacsVersion === "1" ? "NORMATIVE_LISTING_INVALID" : "LEGACY_LISTING_INVALID"); + } +} + +async function verifyListingArtifact( + raw: Record, + resolveKey: ResolvePrimaryClaimKey, +): Promise { + if (raw.signatures !== undefined) return invalid("LISTING_SIGNATURE_INVALID"); + if (raw.dacsVersion === "1") { + // 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"); + } + 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 = decodeComponentSignature(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, + ); + 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, ); - 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 (!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 +240,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[], @@ -283,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), }; @@ -307,9 +367,18 @@ export async function verifyListingRevocation( expectedVersion: number, resolveKey: ResolvePrimaryClaimKey = resolveDemosPrimaryClaimKey, ): Promise { - const scope = stripSignature(raw); + let scope: Record; + let hash: string; + try { + // The SDK reads only canonical JSON: a member with no canonical form + // (e.g. JSON 1e400) can be neither stripped nor signed. + scope = stripSignature(raw); + hash = contentHash(scope); + } catch { + return false; + } 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 || @@ -325,13 +394,6 @@ export async function verifyListingRevocation( ) return false; const sig = decodeSignature(s.value); if (!sig) return false; - let hash: string; - try { - hash = contentHash(scope); - } catch { - // An unknown member with no canonical form (e.g. JSON 1e400) cannot be signed. - return false; - } const message = Buffer.from(`dacs-revocation:v1:${hash}`, "utf8"); const verifiedSigner = await verifyPrimaryClaimSignature( message, sig, listing.signer, s.algorithm, resolveKey, 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..1543074 --- /dev/null +++ b/reference-implementations/dacs-directory/src/catalog/nodeDeployment.ts @@ -0,0 +1,48 @@ +/** + * 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; + +/** 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 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/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/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index d687442..ca0a16b 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,21 @@ 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 // confirmed large tip regression clears derived state before a genesis @@ -157,7 +178,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise, parties.some((p) => p && p.role === "seller" && canonicalDemosAgentClaim(p.primaryClaim) !== null) && await verifiedDiscoveryAgreement(data); } - return isAgreementDocument(data) && canonicalDemosAgentClaim(data.buyer) === canonicalDemosAgentClaim(buyer) && + return isLegacyMvpAgreementDocument(data) && canonicalDemosAgentClaim(data.buyer) === canonicalDemosAgentClaim(buyer) && canonicalDemosAgentClaim(data.seller) !== null && await verifyReferencedArtifactSignature({ kind: "dacs-3-agreement", raw: data, }); 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/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index ff1467d..396afaf 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -7,8 +7,16 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; -import type { Catalog, Registration, ScanState } from "./types.js"; +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"; + +export type { ListingRejectionCode } from "./listingAdmission.js"; export const DATA_DIR = process.env.DACS_DIRECTORY_DATA ?? join(process.cwd(), "data"); const DB_PATH = join(DATA_DIR, "directory.sqlite"); @@ -117,6 +125,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, @@ -140,6 +150,21 @@ db.exec(` ON revocation_candidates(listing_hash, owner, seq) WHERE state = 'pending'; `); +// 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(); + // Add internal omission counters without changing the public diagnostic projection. db.transaction(() => { const columns = db.pragma("table_info(scan_runs)") as Array<{ name: string }>; @@ -200,7 +225,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, [])); @@ -208,7 +235,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 @@ -233,7 +260,49 @@ if (getJson("sr2-anchor-schema-version", 0) < 2) db.transaction(() => { }), }); setJson("sr2-anchor-schema-version", 2); -})(); +}).immediate(); + +// Early Directory builds exposed the internal name `revocationBinding` in +// ListingSummary. DACS-1 uses `revocation`; migrate persisted catalogs once so +// every public surface and restart path now emits the normative wire field. +if (getJson("listing-summary-revocation-schema-version", 0) < 1) db.transaction(() => { + type LegacyListingSummary = ListingSummary & { revocationBinding?: RevocationBinding }; + const catalog = getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); + setJson("catalog", { + ...catalog, + sellers: catalog.sellers.map((seller) => ({ + ...seller, + listings: seller.listings.map((listing) => { + const legacy = listing as LegacyListingSummary; + const { revocationBinding, ...current } = legacy; + const revocation = current.revocation ?? revocationBinding; + return { + ...current, + ...(current.status === "revoked" && revocation ? { revocation } : {}), + }; + }), + })), + }); + setJson("listing-summary-revocation-schema-version", 1); +}).immediate(); + +// Catalog authentication is deliberately narrower than the current SDK's +// buyer-local LR-1..LR-3 decision. Persist that distinction on every existing +// summary so old catalogs cannot imply transaction readiness after upgrade. +if (getJson("listing-summary-readiness-schema-version", 0) < 1) db.transaction(() => { + const catalog = getJson("catalog", { catalogVersion: "1", generatedAt: 0, sellers: [] }); + setJson("catalog", { + ...catalog, + sellers: catalog.sellers.map((seller) => ({ + ...seller, + listings: seller.listings.map((listing) => ({ + ...listing, + transactionReadiness: DIRECTORY_TRANSACTION_READINESS, + })), + })), + }); + setJson("listing-summary-readiness-schema-version", 1); +}).immediate(); /** * Revocation candidates, per listing content hash. Each locator is admitted @@ -295,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'`); @@ -579,22 +655,31 @@ export function pruneFailureHistory(now = Date.now(), batch = 500): number { export const failureHistorySize = (): number => (db.prepare("SELECT COUNT(*) count FROM artifact_failure_history").get() as { count: number }).count; -export type ListingRejectionCode = "SELLER_CLAIM_BINDING" | "OWNER_CLAIM_BINDING"; - export function recordListingRejection( locator: string, registrationClaim: string, code: ListingRejectionCode, + coordinates?: ListingDiagnosticCoordinates, ): void { const now = Date.now(); db.prepare(`INSERT INTO listing_rejections( - locator,registration_claim,reason_code,occurrences,first_seen_at,last_seen_at - ) VALUES (?,?,?,1,?,?) + locator,registration_claim,reason_code,listing_id,listing_version,occurrences,first_seen_at,last_seen_at + ) VALUES (?,?,?,?,?,1,?,?) ON CONFLICT(locator,registration_claim) DO UPDATE SET reason_code=excluded.reason_code, + listing_id=COALESCE(excluded.listing_id,listing_rejections.listing_id), + listing_version=COALESCE(excluded.listing_version,listing_rejections.listing_version), occurrences=listing_rejections.occurrences+1, last_seen_at=excluded.last_seen_at`) - .run(locator, registrationClaim.toLowerCase(), code, now, now); + .run( + locator, + registrationClaim.toLowerCase(), + code, + coordinates?.listingId ?? null, + coordinates?.listingVersion ?? null, + now, + now, + ); } export function clearListingRejection(locator: string, registrationClaim: string): void { @@ -660,6 +745,8 @@ export interface PublicDeadLetterDiagnostic { export interface PublicListingRejectionDiagnostic { locator: string; + listingId?: string; + listingVersion?: number; code: ListingRejectionCode; message: string; occurrences: number; @@ -700,7 +787,7 @@ export interface IndexerDiagnostics { items: PublicDeadLetterDiagnostic[]; }; listingRejectionDiagnostics: { - scope: "listing-registration-binding"; + scope: "listing-admission"; total: number; byCode: Record; query: { locator: string | null; limit: number }; @@ -718,17 +805,14 @@ interface DeadLetterRow { interface ListingRejectionRow { locator: string; + listing_id: string | null; + listing_version: number | null; reason_code: ListingRejectionCode; occurrences: number; first_seen_at: number; last_seen_at: number; } -const LISTING_REJECTION_MESSAGES: Record = { - SELLER_CLAIM_BINDING: "The verified listing seller does not match the registration claim.", - OWNER_CLAIM_BINDING: "The listing anchor owner does not match the registration claim.", -}; - const publicFailure = (code: string): { code: string; message: string } => PUBLIC_FAILURES[code] ? { code, message: PUBLIC_FAILURES[code] } @@ -787,7 +871,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 @@ -796,6 +880,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, @@ -811,7 +897,7 @@ const readIndexerDiagnostics = db.transaction((options: IndexerDiagnosticsOption query: { locator: locator ?? null, limit }, returned: items.length, hasMore, items, }, listingRejectionDiagnostics: { - scope: "listing-registration-binding", + scope: "listing-admission", total: listingRejectionTotal, byCode: Object.fromEntries(listingRejectionCounts.map((row) => [row.reason_code, row.count])), query: { locator: locator ?? null, limit }, diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 70f3a42..a653a46 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -118,15 +118,26 @@ export interface ListingSummary { terms?: Record; status: "active" | "revoked"; /** Required for revoked records; forbidden on active records (DACS-1 RB-3). */ - revocationBinding?: RevocationBinding; + revocation?: RevocationBinding; catalogObservedAt: number; /** Optional catalog observation; never a listing validity or trust signal. */ reachabilityHint?: ReachabilityHint; + /** + * Catalog admission is not buyer/session admission. A buyer must dereference + * the signed artifact and run the current SDK reader with its own policy and + * runtime dependencies before transacting. + */ + transactionReadiness?: DirectoryTransactionReadiness; reputationHint?: ReputationHint; /** Directory extension: machine-readable pointer to a verifier profile envelope. */ inspection?: DirectoryInspectionAffordance; } +export interface DirectoryTransactionReadiness { + disposition: "unassessed"; + reason: string; +} + export interface ReachabilityHint { status: "reachable" | "unreachable" | "unknown"; checkedAt: number; diff --git a/reference-implementations/dacs-directory/src/catalog/wellknown.ts b/reference-implementations/dacs-directory/src/catalog/wellknown.ts index 8fdf135..8055db4 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,83 @@ 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 }; + 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 }; + +/** 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) || + !isListingIndexSummary(entry.summary) || + (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; + // 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) || + 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 + ) { + 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 { @@ -60,9 +131,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 }; @@ -71,7 +148,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); @@ -79,13 +159,13 @@ export async function crawlDomain(domain: string): Promise" over the index bytes. @@ -95,35 +175,23 @@ 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[] = []; if (dacs.bundleBindings?.indexUrl) { - const bindingIndex = await fetchJson(dacs.bundleBindings.indexUrl); + const bindingIndex = await fetchJson(dacs.bundleBindings.indexUrl, request); if (!bindingIndex) return { domain, error: `bundle-binding index unreachable (${dacs.bundleBindings.indexUrl})` }; const expectedBindingHash = (dacs.bundleBindings.indexHash ?? "").replace(/^sha256-/, "").toLowerCase(); const actualBindingHash = sha256Hex(bindingIndex.raw); @@ -147,7 +215,7 @@ export async function crawlDomains(domains: string[]): Promise<{ const agents: WellKnownAgent[] = []; const errors: Array<{ domain: string; error: string }> = []; 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/src/components/CatalogStatus.tsx b/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx index 0a46db2..ea3168b 100644 --- a/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx +++ b/reference-implementations/dacs-directory/src/components/CatalogStatus.tsx @@ -11,6 +11,18 @@ interface Status { syncedToTx: number; chainLatestTx: number | null; txsBehind: number | null; + indexer?: { + listingRejectionDiagnostics?: { + total: number; + items: Array<{ + locator: string; + listingId?: string; + listingVersion?: number; + code: string; + message: string; + }>; + }; + }; } function ago(ts: number): string { @@ -49,17 +61,35 @@ export default function CatalogStatus() { ? "in sync with chain" : `${behind.toLocaleString()} tx${behind === 1 ? "" : "s"} behind chain`; + const rejected = status.indexer?.listingRejectionDiagnostics; return ( - - - {label} · indexed {ago(status.generatedAt)} - +
    + + + {label} · indexed {ago(status.generatedAt)} + + {rejected && rejected.total > 0 && ( +
    + {rejected.total.toLocaleString()} listing candidate{rejected.total === 1 ? "" : "s"} need seller attention +

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

    +
      + {rejected.items.map((item) => ( +
    • + {item.listingId ?? item.locator}{item.listingVersion ? ` · version ${item.listingVersion}` : ""} + {item.message} + {item.code} +
    • + ))} +
    +
    + )} +
    ); } diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 5fcb84d..b59d7f7 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -721,7 +721,7 @@ export default function TryDacs() { try { if (isProcurementSel && selectedProfile) { const deadline = Date.now() + 12 * 60_000; - // The Directory is the discovery surface. Pass its verified listing + // The Directory is the discovery surface. Pass its authenticated catalog candidate // pointer to the Butler; the gateway independently dereferences and // verifies the signed DACS-1 artifact before negotiation. const request: Record = { profileId: selectedProfile.id, ...parsed, paymentRail: selectedPaymentRail }; diff --git a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx index abdc6e7..abee4d3 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyAttestation.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import "@/src/shims/buffer"; import { ed25519Verify, publicKeyFromRaw, dacsXSeparator } from "@kynesyslabs/dacs/crypto"; // Pure module — safe for client bundles (no substrate/demosdk in its chain). -import { verifySignedArtifact } from "@/vendor/dacs-sdk/dist/agent/signedArtifact.js"; +import { verifySignedArtifact } from "@kynesyslabs/dacs"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; diff --git a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx index 17ffcb4..13f71ec 100644 --- a/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx +++ b/reference-implementations/dacs-directory/src/components/VerifyDeal.tsx @@ -11,15 +11,11 @@ import { useState } from "react"; // Side-effect: patches the browser Buffer polyfill with base64url support // (the SDK decodes signature bytes with Buffer.from(x, "base64url")). import "@/src/shims/buffer"; -// Import ONLY pure modules: the package barrel re-exports createAgent, whose -// lazy `import("../substrate")` gets statically traced by Next's bundler and -// drags demosdk (node-only) into the client bundle. (SDK finding: a pure -// "./verify" subpath export would fix this properly — see dacs-sdk#14.) import { ed25519Verify, publicKeyFromRaw } from "@kynesyslabs/dacs/crypto"; import { verifyBundleCore, type BundleVerification, -} from "@/vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +} from "@kynesyslabs/dacs"; import { bundleMatchesRegisteredAnchor, hasRequiredBundleSignatures, @@ -27,17 +23,17 @@ import { refsPassStrictPolicy, type ResolvedArtifact, } from "@/src/catalog/bundlePolicy"; +import { legacySessionAnchorName } from "@/src/catalog/legacySessionAnchorName"; const keyFromDid = (did: string): Uint8Array | null => { const hex = did.match(/(?:^|:)(?:0x)?([0-9a-fA-F]{64})$/)?.[1]; return hex ? Uint8Array.from(Buffer.from(hex, "hex")) : null; }; -// Mirrors the SDK's sessionAnchorName (not exported publicly — dacs-sdk#14). const anchorName: Record string> = { - "dacs-3-agreement": (j) => `dacs3:agreement:${j}`, - "dacs-4-evidence": (j) => `dacs4:evidence:${j}`, - "dacs-2-verifyresult": (j) => `dacs2:verifyrecord:${j}`, + "dacs-3-agreement": legacySessionAnchorName.agreement, + "dacs-4-evidence": legacySessionAnchorName.evidence, + "dacs-2-verifyresult": legacySessionAnchorName.vet, }; async function fetchArtifact(params: string): Promise | null> { diff --git a/reference-implementations/dacs-directory/src/components/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/src/components/listing-publication-recovery.ts b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts index e53a271..e15bc0d 100644 --- a/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts +++ b/reference-implementations/dacs-directory/src/components/listing-publication-recovery.ts @@ -12,6 +12,8 @@ export type PendingListingPublication = { contentHash: string; signedListing: Record; transaction: Record | null; + transactionRef?: string; + anchorReceipt?: Record; registration: Record; stage: PendingPublicationStage; createdAt: number; @@ -25,6 +27,7 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu const signedListing = record(pending?.signedListing); const registration = record(pending?.registration); const transaction = pending?.transaction === null ? null : record(pending?.transaction); + const anchorReceipt = pending?.anchorReceipt === undefined ? undefined : record(pending.anchorReceipt); if ( pending?.version !== 1 || typeof pending.claim !== "string" || !/^did:demos:agent:[0-9a-f]{64}$/.test(pending.claim) || @@ -37,6 +40,10 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu !registration || registration.primaryClaim !== pending.claim || !Array.isArray(registration.listingAnchors) || !registration.listingAnchors.includes(pending.anchorAddress) || (pending.transaction !== null && !transaction) || + (pending.transactionRef !== undefined && ( + typeof pending.transactionRef !== "string" || !/^[0-9a-f]{64}$/.test(pending.transactionRef) + )) || + (pending.anchorReceipt !== undefined && !anchorReceipt) || (pending.stage !== "broadcast-uncertain" && pending.stage !== "confirming" && pending.stage !== "registering") || !Number.isSafeInteger(pending.createdAt) || Number(pending.createdAt) <= 0 ) return null; @@ -51,6 +58,8 @@ export function parsePendingListingPublication(value: unknown): PendingListingPu contentHash: pending.contentHash, signedListing, transaction, + ...(typeof pending.transactionRef === "string" ? { transactionRef: pending.transactionRef } : {}), + ...(anchorReceipt ? { anchorReceipt } : {}), registration, stage: pending.stage, createdAt: Number(pending.createdAt), diff --git a/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts b/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts new file mode 100644 index 0000000..96ed6fc --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkDemosWriteEvidence.ts @@ -0,0 +1,13 @@ +/** + * Server-only compatibility seam for pure Demos finality-evidence helpers. + * Importing the SDK substrate barrel also loads the optional demosdk adapter + * and its unrelated multichain/ZK tree, which the Directory does not use. + */ +export { + assertDemosWriteEvidence, + demosSignedTransactionProofHash, + demosWriteEvidenceToAnchorReceipt, +} from "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js"; +export type { + DemosWriteEvidence, +} from "../vendor/dacs-sdk/dist/substrate/SubstrateAdapter.js"; diff --git a/reference-implementations/dacs-directory/src/sdkListingValidation.ts b/reference-implementations/dacs-directory/src/sdkListingValidation.ts new file mode 100644 index 0000000..3718f08 --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkListingValidation.ts @@ -0,0 +1,12 @@ +/** + * Server-only compatibility seam for the current SDK's public Listing reader + * and publication gates. Keep this separate from sdkVerification.ts: the + * Listing implementation intentionally uses node:net for outbound-address + * policy and must never enter the browser verification bundle. + */ +export { + resolveListingPayloadVerificationCapability, + resolveListingRails, + validateListingArtifact, + type ListingValidationDeps, +} from "../vendor/dacs-sdk/dist/agent/listingValidation.js"; diff --git a/reference-implementations/dacs-directory/src/sdkVerification.ts b/reference-implementations/dacs-directory/src/sdkVerification.ts new file mode 100644 index 0000000..f39ac93 --- /dev/null +++ b/reference-implementations/dacs-directory/src/sdkVerification.ts @@ -0,0 +1,15 @@ +/** + * One compatibility seam for the SDK's public verification exports. + * + * The current SDK exposes these names from its top-level barrel, but that + * barrel also statically re-exports optional Node/multi-chain modules. Resolve + * the public names through their pure implementations until the SDK ships a + * browser-safe public verification subpath. + */ +export { + verifyBundleCore, + type BundleVerification, +} from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; +export { + verifySignedArtifact, +} from "../vendor/dacs-sdk/dist/agent/signedArtifact.js"; diff --git a/reference-implementations/dacs-directory/src/shims/node-net.ts b/reference-implementations/dacs-directory/src/shims/node-net.ts new file mode 100644 index 0000000..b25f64f --- /dev/null +++ b/reference-implementations/dacs-directory/src/shims/node-net.ts @@ -0,0 +1,32 @@ +/** + * 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 (`%[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 && !/^%[0-9a-zA-Z.:-]+$/.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/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/browser-shims.test.ts b/reference-implementations/dacs-directory/test/browser-shims.test.ts new file mode 100644 index 0000000..7acbc14 --- /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", "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", () => { + 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/build-listing.test.ts b/reference-implementations/dacs-directory/test/build-listing.test.ts index 12b8293..623d775 100644 --- a/reference-implementations/dacs-directory/test/build-listing.test.ts +++ b/reference-implementations/dacs-directory/test/build-listing.test.ts @@ -5,15 +5,55 @@ 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"; +import { PINNED_DEMOS_CHAIN_IDENTITY, PINNED_DEMOS_NODE_BUILDS } from "../src/catalog/nodeDeployment.js"; +import { modelSignedRailSnapshot } from "./signed-rail-snapshot.js"; const dataDir = mkdtempSync(join(tmpdir(), "dacs-build-listing-")); 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 })); +// 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); @@ -25,7 +65,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 +84,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 { @@ -57,6 +97,8 @@ test("publisher builds a verifiable metered listing with the AP2 rail/phase bind 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") { @@ -79,47 +121,153 @@ 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; reason: 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"); + // 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, { kind: "metered", unitPrice: { amount: "0.02", currency: "USD" }, unit: "API call", minTotal: { amount: "1", currency: "USD" }, }); - assert.deepEqual(built.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); - assert.deepEqual(built.listing.pipeline, [ + assert.deepEqual(preview.listing.acceptedRails, [{ railId: "ap2:stripe-paymentintents" }]); + assert.deepEqual( + (preview.listing.offering as Record).deliverable, + { kind: "storage-program", accessModel: "public" }, + ); + assert.deepEqual(preview.listing.pipeline, [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, { kind: "pay-ap2", parameters: { rail: "ap2:stripe-paymentintents" } }, - { kind: "deliver-attested-payload" }, + { kind: "deliver-storage-program" }, ]); + + const listingSignature = Buffer.from( + ed25519Sign(Buffer.from(preview.message, "utf8"), privateKey), + ).toString("hex"); + const finalResponse = await POST(request({ + ...base, + identityPresentedAt: identity.identityPresentedAt, + identitySignature, + listingSignature, + })); + assert.equal(finalResponse.status, 200); + const built = await finalResponse.json() as { + listing: Record; + contentHash: string; + logicalAddress: string; + programName: string; + anchorAddress: string; + exists: boolean; + publicationReady: boolean; + tx: { content: { nonce: number; data: [string, Record] } }; + }; + assert.equal(built.publicationReady, true); + assert.equal(built.contentHash, preview.contentHash); assert.equal(built.exists, false); assert.equal(built.logicalAddress, `dacs1:did%3Ademos%3Aagent%3A${keyHex}:metered-ap2:v1`); assert.ok(!built.programName.includes(":"), "the producer-held Demos name must be colon-free"); + assert.equal(built.programName, built.logicalAddress.replaceAll(":", "%3A")); assert.equal(built.tx.content.nonce, 1); assert.equal(built.tx.content.data[1].salt, "", "SDK #70 uses the live empty-salt convention"); + assert.deepEqual(built.tx.content.data[1].data, built.listing); + assert.equal((built.tx.content.data[1].metadata as Record).contentHash, built.contentHash); assert.equal( built.anchorAddress, deriveStorageAddress(`0x${keyHex}`, built.programName, 1, LIVE_STORAGE_SALT), ); assert.equal(built.contentHash.length, 64); - const listingSignature = Buffer.from( - ed25519Sign(Buffer.from(built.message, "utf8"), privateKey), - ).toString("hex"); - assert.ok(await verifyListing({ - ...built.listing, - signature: { algorithm: "ed25519", signer: claim, value: listingSignature }, - })); + assert.ok(await verifyListing(built.listing)); + 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) => { + 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") { + 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; } @@ -135,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" @@ -144,7 +294,12 @@ test("publisher recovers an immutable owner-bound listing instead of creating a return new Response(null, { status: 404 }); }; try { - const builtResponse = await POST(request(finalInput)); + const previewResponse = await POST(request(finalInput)); + const preview = await previewResponse.json() as { message: string }; + const listingSignature = Buffer.from( + ed25519Sign(Buffer.from(preview.message, "utf8"), privateKey), + ).toString("hex"); + const builtResponse = await POST(request({ ...finalInput, listingSignature })); assert.equal(builtResponse.status, 200); const built = await builtResponse.json() as { listing: Record; @@ -153,13 +308,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") { @@ -200,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, @@ -209,6 +418,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 }; @@ -235,3 +460,73 @@ 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`); + } + // 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; + } +}); + +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/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/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/confirm-listing.test.ts b/reference-implementations/dacs-directory/test/confirm-listing.test.ts index 37882e2..5cc734f 100644 --- a/reference-implementations/dacs-directory/test/confirm-listing.test.ts +++ b/reference-implementations/dacs-directory/test/confirm-listing.test.ts @@ -2,17 +2,23 @@ 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"); +const { PINNED_DEMOS_CHAIN_IDENTITY, nodeBuildRejection, pinnedChainIdentity, pinnedNodeBuilds } = await import( + "../src/catalog/nodeDeployment.js" +); +const { demosSignedTransactionProofHash } = await import( + "../vendor/dacs-sdk/dist/substrate/demosWriteEvidence.js" +); +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"); const sellerClaim = `did:demos:agent:${keyHex}`; const owner = `0x${keyHex}`; const anchorAddress = `stor-${"ab".repeat(20)}`; -const programName = "dacs1-ZGFjczEtdmVyaWZpZWQ"; const identity = { bundleVersion: "1", presentedBy: sellerClaim, @@ -40,7 +46,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: [ @@ -60,9 +70,61 @@ 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); +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: { 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); +// 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, @@ -76,21 +138,236 @@ 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 () => { +/** 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; + 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; + /** 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({ + success: true, + data: listing, + owner, + 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" }); + } + if (call.message === "getBlockByNumber" && call.data.blockNumber === 5) { + return Response.json({ + result: 200, + response: { + status: "confirmed", + number: 5, + hash: blockHash, + content: { timestamp: 1_786_360_000, ordered_transactions: [transactionHash] }, + validation_data: { signatures: { [`0x${"aa".repeat(32)}`]: `0x${"bb".repeat(64)}` } }, + ...overrides.block, + }, + }); + } + 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 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 }, ...overrides.first }, + }); + } + return Response.json({ result: 500, response: null }); + }; + return calls; +} + +test("a node-reported inclusion is an unauthenticated observation, never a receipt or a confirmation", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = async () => Response.json({ success: true, data: listing, owner, programName }); + stubChain(); 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; + // 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 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))); + 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; + } +}); + +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; nodeObservation: Observation }; + assert.equal(body.state, "finality-unverified"); + assert.equal(body.nodeObservation.transactionRef, transactionHash); + assert.equal( + body.nodeObservation.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); + 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 { + globalThis.fetch = originalFetch; + } +}); + +test("listing confirmation never registers before the write is in a node-confirmed block", async () => { + const originalFetch = globalThis.fetch; + try { + for (const block of [ + { 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: "" }, + // 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)); + assert.equal(response.status, 202, JSON.stringify(block)); + const body = await response.json() as Record; + assert.equal(body.confirmed, false); + assert.equal(body.state, "inclusion-pending"); + assert.equal(body.nodeObservation, undefined); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +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 { + 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.confirmed, false); + assert.equal(body.state, "inclusion-failed"); + assert.equal(body.nodeObservation, undefined); + } } finally { globalThis.fetch = originalFetch; } @@ -102,7 +379,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, @@ -112,7 +389,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; } @@ -124,8 +403,299 @@ 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 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; nodeObservation?: Observation }; + return response.status === 200 ? body.nodeObservation!.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 }], + ["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)); + 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 otherHash = "ef".repeat(32); + try { + 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) { + // 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 }; + assert.equal(body.state, "inclusion-pending", name); + assert.equal(body.confirmed, false, name); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +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" }; + 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; confirmed?: unknown; nodeObservation?: Observation }, + }; + }; + 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.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`; + 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 is never reported as 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/current-indexer.test.ts b/reference-implementations/dacs-directory/test/current-indexer.test.ts index 33949cb..7ae52ac 100644 --- a/reference-implementations/dacs-directory/test/current-indexer.test.ts +++ b/reference-implementations/dacs-directory/test/current-indexer.test.ts @@ -6,12 +6,17 @@ 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 { 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))); @@ -26,7 +31,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 = {}) => ({ @@ -39,7 +46,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 }, @@ -217,6 +224,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, @@ -1039,3 +1055,117 @@ 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 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); + 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/dead-letter-diagnostics.test.ts b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts index 52a30be..e06702c 100644 --- a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts +++ b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts @@ -111,15 +111,24 @@ test("storage cause diagnostics are actionable without claiming DACS absence", ( test("listing binding rejections are persistent, public-safe, filterable, and recoverable", () => { const target = locator("7"); const claim = `did:demos:agent:${"7".repeat(64)}`; - store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); - store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING"); + store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING", { + listingId: "seller-service", + listingVersion: 3, + }); + store.recordListingRejection(target, claim, "OWNER_CLAIM_BINDING", { + listingId: "seller-service", + listingVersion: 3, + }); const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }); + assert.equal(diagnostics.listingRejectionDiagnostics.scope, "listing-admission"); assert.equal(diagnostics.listingRejectionDiagnostics.total, 1); assert.equal(diagnostics.listingRejectionDiagnostics.returned, 1); assert.equal(diagnostics.listingRejectionDiagnostics.byCode.OWNER_CLAIM_BINDING, 1); assert.deepEqual(diagnostics.listingRejectionDiagnostics.items[0], { locator: target, + listingId: "seller-service", + listingVersion: 3, code: "OWNER_CLAIM_BINDING", message: "The listing anchor owner does not match the registration claim.", occurrences: 2, @@ -132,6 +141,20 @@ test("listing binding rejections are persistent, public-safe, filterable, and re assert.equal(store.indexerDiagnostics({ deadLetterLocator: target }).listingRejectionDiagnostics.returned, 0); }); +test("normative listing admission failures expose stable public-safe diagnostics", () => { + const target = locator("8"); + const claim = `did:demos:agent:${"8".repeat(64)}`; + store.recordListingRejection(target, claim, "VERIFICATION_METHOD_INVALID"); + + const diagnostics = store.indexerDiagnostics({ deadLetterLocator: target }) + .listingRejectionDiagnostics; + assert.equal(diagnostics.scope, "listing-admission"); + assert.equal(diagnostics.byCode.VERIFICATION_METHOD_INVALID, 1); + assert.equal(diagnostics.items[0].code, "VERIFICATION_METHOD_INVALID"); + assert.match(diagnostics.items[0].message, /registered structured variant/); + assert.doesNotMatch(JSON.stringify(diagnostics), new RegExp(claim)); +}); + test("cursor progress diagnostics distinguish caught-up, stalled, and unknown cursors", () => { const now = 1_000_000; assert.equal(cursorStallThresholdSeconds("60"), 60); diff --git a/reference-implementations/dacs-directory/test/discovery-contract.test.ts b/reference-implementations/dacs-directory/test/discovery-contract.test.ts index 2b355b6..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/); } }); @@ -89,12 +92,14 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { assert.equal(listingSummarySchema.properties.inspection.properties.artifactType.const, "directory-service-profile"); assert.ok(listingSummarySchema.properties.inspection.properties.maturity.enum.includes("listed")); assert.equal(listingSummarySchema.properties.inspection.properties.href.format, "uri-reference"); + assert.ok(listingSummarySchema.required.includes("transactionReadiness")); + assert.equal(listingSummarySchema.properties.transactionReadiness.properties.disposition.const, "unassessed"); assert.ok(listingSummarySchema.properties.artifactProfile.enum.includes("fixture-listing")); assert.ok(listingSummarySchema.properties.pricing.properties.kind.enum.includes("metered")); assert.equal(listingSummarySchema.properties.pricing.properties.minTotalHint.type, "string"); - assert.ok(listingSummarySchema.allOf[0].then.required.includes("revocationBinding")); + assert.ok(listingSummarySchema.allOf[0].then.required.includes("revocation")); assert.equal( - listingSummarySchema.properties.revocationBinding.properties.markerContentHash.pattern, + listingSummarySchema.properties.revocation.properties.markerContentHash.pattern, "^[0-9a-f]{64}$", ); const filters = document.paths["/api/dacs/listings"].get.parameters.map((parameter) => parameter.name); @@ -116,7 +121,16 @@ test("OpenAPI and JSON Schema describe the listing discovery surface", () => { assert.ok(catalogStatusSchema.required.includes("secondsSinceCursorAdvanced")); assert.ok(catalogStatusSchema.required.includes("cursorStalled")); assert.equal(deadLetterDiagnosticSchema.properties.retryState.const, "exhausted"); - assert.ok(listingRejectionDiagnosticSchema.properties.code.enum.includes("OWNER_CLAIM_BINDING")); + assert.deepEqual([...listingRejectionDiagnosticSchema.properties.code.enum], [ + "SELLER_CLAIM_BINDING", + "OWNER_CLAIM_BINDING", + "NORMATIVE_LISTING_INVALID", + "VERIFICATION_METHOD_INVALID", + "LISTING_SIGNATURE_INVALID", + "IDENTITY_PRESENTATION_INVALID", + "LEGACY_LISTING_INVALID", + "DECLARED_CONTENT_HASH_MISMATCH", + ]); assert.equal(indexerScanRunSchema.additionalProperties, false); assert.ok(!("error" in indexerScanRunSchema.properties)); }); diff --git a/reference-implementations/dacs-directory/test/fixtures/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/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/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/inspection.test.ts b/reference-implementations/dacs-directory/test/inspection.test.ts index 4d8a117..3fe2d06 100644 --- a/reference-implementations/dacs-directory/test/inspection.test.ts +++ b/reference-implementations/dacs-directory/test/inspection.test.ts @@ -80,6 +80,8 @@ test("directory service profile envelope is listed-only and does not claim sourc assert.equal(envelope.maturity, envelope.artifact.maturityProfile.maturity); assert.equal(envelope.artifact.maturityProfile.noReputationClaim, true); assert.equal(envelope.artifact.maturityProfile.noLivePaymentClaim, true); + assert.equal(envelope.artifact.transactionReadiness.disposition, "unassessed"); + assert.match(envelope.artifact.transactionReadiness.reason, /current SDK Listing reader/); assert.deepEqual(envelope.artifact.limitations, [ "roster maturity hint", "not reputation evidence", @@ -96,5 +98,6 @@ test("listing discovery can add the inspection affordance without changing the s assert.equal(withInspection.anchor.locator, listing.anchor.locator); assert.equal(withInspection.inspection?.artifactType, "directory-service-profile"); assert.equal(withInspection.inspection?.href, inspectServicePath(listing)); + assert.equal(withInspection.transactionReadiness?.disposition, "unassessed"); assert.equal(listing.inspection, undefined); }); diff --git a/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts new file mode 100644 index 0000000..8c851b6 --- /dev/null +++ b/reference-implementations/dacs-directory/test/legacy-deal-evidence.test.ts @@ -0,0 +1,168 @@ +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); +}); + +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/listing-admission.test.ts b/reference-implementations/dacs-directory/test/listing-admission.test.ts new file mode 100644 index 0000000..b251981 --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-admission.test.ts @@ -0,0 +1,358 @@ +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 { 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 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 { 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 }), +}); + +/** 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 = {}, + 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: refs.presentedBy, 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: refs.signer, 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, + ); +}); + +// 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"], + ); + // 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, + listing.listingId === "indexed-advisory" ? advisory : seller, + listing.listingId, + ); + } + } finally { + 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-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-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(); + } +}); 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..07824a7 --- /dev/null +++ b/reference-implementations/dacs-directory/test/listing-summary-migration.test.ts @@ -0,0 +1,94 @@ +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"; +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", + transactionReadiness: { disposition: "ready" }, + revocationBinding, + catalogObservedAt: 1, + }, { + listingId: "listing-2", + version: 1, + status: "active", + }], + 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); +}); + +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(); } +}); diff --git a/reference-implementations/dacs-directory/test/listing-verification.test.ts b/reference-implementations/dacs-directory/test/listing-verification.test.ts index 70a04e9..76082a9 100644 --- a/reference-implementations/dacs-directory/test/listing-verification.test.ts +++ b/reference-implementations/dacs-directory/test/listing-verification.test.ts @@ -1,14 +1,33 @@ 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 { identityBundleHash } from "@kynesyslabs/dacs/identity"; import { ed25519Sign, privateKeyFromSeed, publicKeyFromSeed, rawPublicKey } from "@kynesyslabs/dacs/crypto"; -import { verifyListing } 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); 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 +38,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 +66,7 @@ function signedCurrentListing( tags: ["sig5"], deliverable: { kind: "storage-program" }, }, - buyerRequirement: { kind: "none" }, + buyerRequirement: { requirementVersion: "1", required: [] }, pipeline: [ { kind: "negotiate-fixed-price" }, { kind: "commit-agreement" }, @@ -69,12 +90,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 }, + }; - assert.equal(verified?.signer, claim); - assert.equal(verified?.sellerClaim, claim); + 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(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 () => { @@ -120,6 +164,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 +278,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({ @@ -198,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/local-workflow.test.mjs b/reference-implementations/dacs-directory/test/local-workflow.test.mjs new file mode 100644 index 0000000..41f1612 --- /dev/null +++ b/reference-implementations/dacs-directory/test/local-workflow.test.mjs @@ -0,0 +1,204 @@ +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("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-")); + 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 +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))) { + 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); +} +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 +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)) { + 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); +} +// 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 }); + 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", + "objects-symlink", "objects-pack-symlink", "objects-fanout-symlink", "common-directory", + "shared-objects", "http-alternates", "dangling-alternates", "shallow-symlink", "fetch-head-symlink", "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 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 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"); + } + } + 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.deepEqual(snapshot(source), before, `${kind}: external checkout and object store are unchanged`); + } +}); + +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); + if (existing) { + mkdirSync(join(f.cwd, "vendor")); + cpSync(source, join(f.cwd, "vendor/dacs-sdk"), { recursive: true }); + } + 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"], + ]); + } +}); diff --git a/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts b/reference-implementations/dacs-directory/test/malformed-artifacts.test.ts index fa69174..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:", @@ -39,7 +41,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 = {}) => ({ @@ -52,7 +55,8 @@ const listingScope = { claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], }; -const listing = { ...listingScope, signature: { algorithm: "ed25519", signer: seller, value: await signature(contentHash(listingScope), PREFIXES.listing, 1) } }; +// The SDK legacy-MVP read profile carries a bare Ed25519 value; agentId is the signer. +const listing = { ...listingScope, signature: await signature(contentHash(listingScope), PREFIXES.listing, 1) }; const verifiedListing = await verifyListing(listing); assert.ok(verifiedListing); const markerScope = { listingId: "svc", listingVersion: 1, listingContentHash: verifiedListing.contentHash, revokedAt: 1 }; @@ -119,6 +123,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 }); @@ -144,13 +156,13 @@ 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); const [indexed] = catalog.sellers[0].listings; assert.equal(indexed.status, "revoked"); - assert.equal(indexed.revocationBinding?.markerAnchor.locator, validMarker); + assert.equal(indexed.revocation?.markerAnchor.locator, validMarker); const queue = new Database(join(dataDirectory, "directory.sqlite"), { readonly: true }); try { assert.deepEqual(queue.prepare("SELECT locator FROM revocation_candidates WHERE listing_hash = ? AND state = 'pending'") @@ -333,7 +345,7 @@ async function currentDeal(jobId: string, offset: number, paymentTxRefs: unknown const listingRaw: Obj = { dacsVersion: "1", listingVersion: 1, listingId: "svc-current", requiredCapabilities: ["SR-2"], seller: { identity: { bundleVersion: "1", presentedBy: seller, presentedAt: 1, claims: [{ ref: seller }], 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 }, @@ -411,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 }; @@ -452,13 +464,14 @@ test("reindex drops malformed carried bindings and still indexes every later sel chain({ [listingAnchor]: { data: JSON.stringify(listing) } }, [listingAnchor]); const carried = JSON.parse(withMember(binding, canonDeepText)) as BundleBinding; assert.doesNotThrow(() => JSON.stringify(carried)); - assert.throws(() => contentHash(carried), RangeError); + // No canonical form: the pinned SDK refuses its nesting before recursing (DacsError, not RangeError). + assert.throws(() => contentHash(carried)); resetIndex([ { primaryClaim: buyer, displayName: "Carrier", listingAnchors: [], bundleBindings: [carried, binding] }, { 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); @@ -475,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/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/rail-registry.test.ts b/reference-implementations/dacs-directory/test/rail-registry.test.ts new file mode 100644 index 0000000..9be25b4 --- /dev/null +++ b/reference-implementations/dacs-directory/test/rail-registry.test.ts @@ -0,0 +1,164 @@ +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, + PUBLISHER_IN_CODE_RAIL_DEFINITIONS, + RAIL_ID_PATTERN, + 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"); +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 }>; +}; +// 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: false, +})); +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("/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")}`; + 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; + 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 = { + 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" }, + }; + // 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.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}`); + 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 } }], + 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)); +}); + +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: "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" }, + { disposition: "indeterminate", reason: "registry-unavailable" }, + { disposition: "indeterminate" }, + ]) { + assert.equal(railAuthorityPermitsPublication(result), false, JSON.stringify(result)); + } +}); 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 4dbdb25..bfb9848 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); @@ -44,7 +46,8 @@ const listingScope = { claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], }; -const listing = { ...listingScope, signature: { algorithm: "ed25519", signer: seller, value: await signature(contentHash(listingScope), "dacs-listing:v1:", 1) } }; +// The SDK legacy-MVP read profile carries a bare Ed25519 value; agentId is the signer. +const listing = { ...listingScope, signature: await signature(contentHash(listingScope), "dacs-listing:v1:", 1) }; const verifiedListing = await verifyListing(listing); assert.ok(verifiedListing); const listingHash = verifiedListing.contentHash; @@ -68,6 +71,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; @@ -78,6 +83,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" } }); } @@ -93,6 +100,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); @@ -140,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]; @@ -668,6 +676,157 @@ 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 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"], + [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, or fetch rejects. + Object.defineProperty(programs, locator(61), { + enumerable: true, + 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}`); + } +}); + +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] }))], + // 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) { + 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 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/scan-robustness.test.ts b/reference-implementations/dacs-directory/test/scan-robustness.test.ts index be14e3a..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"); @@ -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:", @@ -55,7 +57,8 @@ const listingScope = { claimRequirements: [], supportedNegotiation: ["negotiate-fixed-price"], supportedPaymentRails: ["pay-dem"], supportedDelivery: ["deliver-attested-payload"], }; -const listing = { ...listingScope, signature: { algorithm: "ed25519", signer: seller, value: await signature(contentHash(listingScope), PREFIXES.listing, 1) } }; +// The SDK legacy-MVP read profile carries a bare Ed25519 value; agentId is the signer. +const listing = { ...listingScope, signature: await signature(contentHash(listingScope), PREFIXES.listing, 1) }; const verifiedListing = await verifyListing(listing); assert.ok(verifiedListing); const markerScope = { listingId: "svc", listingVersion: 1, listingContentHash: verifiedListing.contentHash, revokedAt: 1 }; @@ -136,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(); } @@ -179,7 +182,10 @@ 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") } }; - for (const depth of [124, 125, 126, 127, 128, 129, 4096, 4098, 4100]) { + // 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(); @@ -346,6 +352,48 @@ 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(); + 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: () => {}, + 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); + 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 }), /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 () => { resetIndex([{ primaryClaim: buyer, displayName: "Mismatched seller", listingAnchors: [locator(90)] }, later], 12); chain({ [locator(90)]: { data: JSON.stringify(listing) } }, [locator(90)]); @@ -357,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/sdk-reader-verifiers.test.ts b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts new file mode 100644 index 0000000..1c95369 --- /dev/null +++ b/reference-implementations/dacs-directory/test/sdk-reader-verifiers.test.ts @@ -0,0 +1,129 @@ +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 { listingUpgradeReaderDeps, listingUpgradeRefusal } from "../src/catalog/listingUpgradeAcceptance.js"; +import { + sdkIdentityPresentationVerifier, + sdkListingSignatureVerifier, +} from "../src/catalog/sdkReaderVerifiers.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); +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: "demos-native:DEM" } }, + { kind: "deliver-storage-program" }, + ], + pricing: { kind: "fixed", price: { amount: "1", currency: "DEM" } }, + acceptedRails: [{ railId: "demos-native: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"); + +/** 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(); + 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("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 () => { + 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); + } +}); diff --git a/reference-implementations/dacs-directory/test/security.test.ts b/reference-implementations/dacs-directory/test/security.test.ts index 65632c8..21050a2 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)}`; @@ -136,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: [] }, @@ -190,6 +198,75 @@ 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)}` }, + summary: { title: "Service", category: "services.other", tags: ["a"] }, + 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: {} }); + + 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", () => { const prior = process.env.DACS_TRUST_PROXY; try { 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..6f6357f --- /dev/null +++ b/reference-implementations/dacs-directory/test/setup-sdk.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +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"; + +// 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, stale }) { + const root = mkdtempSync(join(tmpdir(), "dacs-setup-sdk-")); + try { + mkdirSync(join(root, "scripts")); + 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)); + 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 }); + 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 }); + } +} + +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"); + 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); + 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); + + // 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 %, 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(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(scratch, { recursive: true, force: true }); + } + } +}); 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; + }; +} diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index fef7b72..a025e17 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -33,7 +33,8 @@ import { import { addRevocationCandidate, isListingRevocationCandidate } 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")}`; @@ -53,17 +54,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); }); @@ -104,7 +100,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: [ @@ -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,11 +131,18 @@ 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); }); -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"), @@ -260,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", () => { @@ -370,10 +389,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" }, @@ -408,7 +425,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" }, @@ -424,8 +441,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); @@ -543,7 +560,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; @@ -589,6 +606,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")}`; @@ -597,10 +674,7 @@ test("standalone revocation verifier rejects a signed revokedAt that is not a no const listingSignature = Buffer.from( await ed25519Sign(listingMessage, privateKeyFromSeed(markerSeed)), ).toString("hex"); - const verified = await verifyListing({ - ...markerListing, - signature: { algorithm: "ed25519", signer: markerDid, value: listingSignature }, - }); + const verified = await verifyListing({ ...markerListing, signature: listingSignature }); assert.ok(verified); if (!verified) return; @@ -663,10 +737,7 @@ test("a revocation candidate with no canonical form never stops the candidate lo const listingSignature = Buffer.from( await ed25519Sign(listingMessage, privateKeyFromSeed(seed)), ).toString("hex"); - const verified = await verifyListing({ - ...listing, - signature: { algorithm: "ed25519", signer: did, value: listingSignature }, - }); + const verified = await verifyListing({ ...listing, signature: listingSignature }); assert.ok(verified); if (!verified) return; @@ -712,7 +783,7 @@ test("revocation verification work per call is bounded and reports what it exami const perPass = 16; 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: { algorithm: "ed25519", signer: did, value: listingSignature } }); + const verified = await verifyListing({ ...listing, signature: listingSignature }); assert.ok(verified); if (!verified) return; const scope = { listingId: listing.listingId, listingVersion: listing.listingVersion, listingContentHash: verified.contentHash, revokedAt: 1 }; @@ -791,7 +862,7 @@ test("indexer rejects a shape-valid listing with a fake signature", async () => test("indexer skips a listing with no canonical form and still indexes the next listing", 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 } }; + const signed = { ...listing, signature: value }; const malformedAnchor = `stor-${"d".repeat(40)}`; const validAnchor = `stor-${"e".repeat(40)}`; // JSON `1e400` parses to Infinity, which has no canonical (CF-1) number form. @@ -833,7 +904,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" }, @@ -850,7 +921,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"); @@ -860,7 +931,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"); }); 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..a1c75ee --- /dev/null +++ b/reference-implementations/dacs-directory/test/wellknown-crawl.test.ts @@ -0,0 +1,193 @@ +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) }, + 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)}`; + 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/], + ["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)); + 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"); +}); 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" ]