Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ vendor directory.
| `DACS_SCAN_REPLAY_DEPTH` | No | Finalized transaction overlap 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`) |
| `DACS_REACHABILITY_CONCURRENCY` | No | Concurrent pinned HTTPS reachability probes; defaults to `5` (bounded to `1..10`) |
| `DACS_RECIPE_POLICIES` | For tier elevation | JSON array of version-pinned DACS-2 recipe policies (`scheme`, `recipeVersion`, `methods`, `defaultMaxAgeSec`, `availability`, `trustedResultSigners`); absent/invalid policy fails closed to `self-declared` |
| `DACS_TRUST_PROXY` | No | Set to `1` only behind a trusted proxy that overwrites client-IP headers; otherwise the in-process rate limiter is disabled and the deployment must enforce its edge limit |
| `NEXT_PUBLIC_DIRECTORY_URL` | Production | Public origin used by canonical URLs, sitemap, `llms.txt`, and machine-discovery documents; defaults to `http://localhost:3400`, which silently poisons production canonical URLs and the sitemap — the server logs a warning when unset in production |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ DACS Directory discovers signed, chain-anchored agent services for humans and so

GET ${base}/api/dacs/listings supports category, repeated tag, credential, primaryClaim, identityTier, rail, priceMax, minCompletionRate, minRating, cursor and limit. Directory extensions are q and profile. Every result includes an anchor and contentHash; dereference the listing-detail URL before engaging. identityTier is derived only from fresh, passing, version-pinned DACS-2 verifiedBy evidence; missing recipe policy fails closed to self-declared.

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.
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.

## Publication diagnostics

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default async function ServicePage({ params }: { params: Promise<Params>
<span className={`badge ${identity.chipClass}`}>{identity.label}</span>
<span className={`badge ${evidence.listing.kind === "current" ? "ok" : ""}`}>{evidence.listing.label}</span>
{seller.ownerRegistered && <span className="badge ok">owner-registered</span>}
{engagementEndpoint && <span className={`badge ${evidence.reachability.kind === "reachable" ? "ok" : ""}`}>{evidence.reachability.label}</span>}
{!isFixtureListing && !seller.ownerRegistered && seller.discovered && <span className="badge">discovered on-chain</span>}
{isFixtureListing && <span className="badge">not chain anchored</span>}
{!isFixtureListing && !seller.ownerRegistered && !seller.discovered && (
Expand Down Expand Up @@ -124,7 +125,7 @@ export default async function ServicePage({ params }: { params: Promise<Params>
<li><span className={seller.cci.length ? "check ok" : "check"}>{seller.cci.length ? "✓" : "–"}</span><div><strong>Identity links</strong><p>{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"}</p></div></li>
<li><span className={isFixtureListing ? "check" : "check ok"}>{isFixtureListing ? "–" : "✓"}</span><div><strong>Listing</strong><p>{isFixtureListing ? "Fixture machine contract and content hash match; no chain anchor claimed" : "Signature and chain anchor verified"}</p></div></li>
<li><span className="check">{engagementEndpoint ? "•" : "–"}</span><div><strong>Endpoint declaration</strong><p>{evidence.endpoint.label}; this says nothing about availability</p></div></li>
<li><span className="check">–</span><div><strong>Reachability</strong><p>{evidence.reachability.label}</p></div></li>
<li><span className={evidence.reachability.kind === "reachable" ? "check ok" : "check"}>{evidence.reachability.kind === "reachable" ? "✓" : "–"}</span><div><strong>Reachability</strong><p>{evidence.reachability.label}</p></div></li>
<li><span className={evidence.deals.completed ? "check ok" : "check"}>{evidence.deals.completed ? "✓" : "–"}</span><div><strong>Two-sided deal evidence</strong><p>{evidence.deals.label}. {evidence.deals.explanation}.</p></div></li>
</ul>
</aside>
Expand Down
229 changes: 229 additions & 0 deletions reference-implementations/dacs-directory/src/catalog/boundedHttps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { lookup } from "node:dns/promises";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import type { LookupFunction } from "node:net";

const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_MAX_REDIRECTS = 3;
const DEFAULT_TIMEOUT_MS = 15_000;

function ipv6Words(address: string): number[] | null {
let input = address.toLowerCase().split("%", 1)[0];
const dotted = input.match(/^(.*:)(\d{1,3}(?:\.\d{1,3}){3})$/);
if (dotted) {
const octets = dotted[2].split(".").map(Number);
if (octets.some((part) => part < 0 || part > 255)) return null;
input = `${dotted[1]}${((octets[0] << 8) | octets[1]).toString(16)}:${
((octets[2] << 8) | octets[3]).toString(16)
}`;
}
const halves = input.split("::");
if (halves.length > 2) return null;
const parseHalf = (half: string) => half ? half.split(":").map((word) => Number.parseInt(word, 16)) : [];
const left = parseHalf(halves[0]);
const right = halves.length === 2 ? parseHalf(halves[1]) : [];
if ([...left, ...right].some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff)) return null;
if (halves.length === 1) return left.length === 8 ? left : null;
const zeros = 8 - left.length - right.length;
return zeros >= 1 ? [...left, ...Array<number>(zeros).fill(0), ...right] : null;
}

/** Conservative global-unicast policy for untrusted outbound targets. */
export function isPrivateAddress(address: string): boolean {
if (isIP(address) === 4) {
const p = address.split(".").map(Number);
return (
p[0] === 0 || p[0] === 10 || p[0] === 127 ||
(p[0] === 100 && p[1] >= 64 && p[1] <= 127) ||
(p[0] === 169 && p[1] === 254) ||
(p[0] === 172 && p[1] >= 16 && p[1] <= 31) ||
(p[0] === 192 && (p[1] === 0 || p[1] === 168)) ||
(p[0] === 198 && (p[1] === 18 || p[1] === 19 || (p[1] === 51 && p[2] === 100))) ||
(p[0] === 203 && p[1] === 0 && p[2] === 113) ||
p[0] >= 224
);
}
if (isIP(address) === 6) {
const words = ipv6Words(address);
if (!words) return true;
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {
return isPrivateAddress(
`${words[6] >> 8}.${words[6] & 0xff}.${words[7] >> 8}.${words[7] & 0xff}`,
);
}
// Accept only global-unicast 2000::/3, excluding reserved/documentation
// and transition assignments that can tunnel an unchecked IPv4 target.
if ((words[0] & 0xe000) !== 0x2000) return true;
if (words[0] === 0x2001 && (words[1] & 0xfe00) === 0) return true;
if (words[0] === 0x2001 && words[1] === 0x0db8) return true;
if (words[0] === 0x2002) return true;
if (words[0] === 0x3fff && (words[1] & 0xf000) === 0) return true;
return false;
}
return true;
}

export class OutboundTargetError extends Error {}

export interface VettedUrl {
url: URL;
/** The specific resolved address the caller MUST connect to. */
ip: string;
}

/** Resolve, reject every non-public answer, and pin one approved address. */
export async function validatePublicHttpsUrl(raw: string): Promise<VettedUrl> {
let url: URL;
try { url = new URL(raw); } catch { throw new OutboundTargetError("invalid URL"); }
if (url.href.length > 2_048 || url.protocol !== "https:" || url.username || url.password ||
(url.port && url.port !== "443")) {
throw new OutboundTargetError("outbound URLs must use public HTTPS on port 443");
}
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (!hostname || hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
throw new OutboundTargetError("local hostnames are not allowed");
}
let resolved: Array<{ address: string; family: number }>;
if (isIP(hostname)) {
resolved = [{ address: hostname, family: isIP(hostname) }];
} else {
try { resolved = await lookup(hostname, { all: true, verbatim: true }); }
catch (error) { throw new Error("target DNS lookup failed", { cause: error }); }
}
if (resolved.length === 0 || resolved.some((record) => isPrivateAddress(record.address))) {
throw new OutboundTargetError("URL resolves to a non-public address");
}
if (!isIP(hostname)) url.hostname = hostname;
return { url, ip: resolved[0].address };
}

function beforeDeadline<T>(promise: Promise<T>, deadline: number): Promise<T> {
const remaining = deadline - Date.now();
if (remaining <= 0) return Promise.reject(new Error("whole-request timeout"));
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("whole-request timeout")), remaining);
timer.unref();
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(error) => { clearTimeout(timer); reject(error); },
);
});
}

function pinnedLookup(ip: string): LookupFunction {
const family = isIP(ip) || 4;
return function (_hostname: string, options: unknown, callback?: unknown) {
const cb = (typeof options === "function" ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: unknown,
family?: number,
) => void;
const wantsAll = typeof options === "object" && options !== null && (options as { all?: boolean }).all;
if (wantsAll) cb(null, [{ address: ip, family }], undefined);
else cb(null, ip, family);
} as unknown as LookupFunction;
}

export interface BoundedHttpsResponse {
status: number;
location: string | null;
contentEncoding: string | null;
body: Buffer;
finalUrl: string;
}

interface RequestOptions {
method?: "GET" | "HEAD";
accept?: string;
maxBytes?: number;
maxRedirects?: number;
timeoutMs?: number;
}

function requestPinned(url: URL, ip: string, options: Required<RequestOptions>): Promise<BoundedHttpsResponse> {
return new Promise((resolve, reject) => {
const signal = AbortSignal.timeout(options.timeoutMs);
const req = httpsRequest(url, {
agent: false,
method: options.method,
lookup: pinnedLookup(ip),
servername: isIP(url.hostname.replace(/^\[|\]$/g, "")) ? undefined : url.hostname,
signal,
headers: {
accept: options.accept,
"accept-encoding": "identity",
host: url.host,
},
timeout: options.timeoutMs,
}, (res) => {
const announced = Number(res.headers["content-length"] ?? 0);
if (options.method !== "HEAD" && announced > options.maxBytes) {
res.destroy(new Error("response too large"));
return;
}
const encodingHeader = res.headers["content-encoding"];
const contentEncoding = Array.isArray(encodingHeader) ? encodingHeader[0] ?? null : encodingHeader ?? null;
// Compression is disabled so the byte cap is also the post-decoding cap.
// A peer that ignores identity encoding is rejected rather than decoded.
if (options.method !== "HEAD" && contentEncoding && contentEncoding.toLowerCase() !== "identity") {
res.destroy(new Error("encoded responses are not accepted"));
return;
}
const chunks: Buffer[] = [];
let size = 0;
res.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > options.maxBytes) {
res.destroy(new Error("response too large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => {
const locationHeader = res.headers.location;
resolve({
status: res.statusCode ?? 0,
location: Array.isArray(locationHeader) ? locationHeader[0] ?? null : locationHeader ?? null,
contentEncoding,
body: Buffer.concat(chunks),
finalUrl: url.href,
});
});
res.on("error", reject);
});
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", reject);
req.end();
});
}

/**
* Credential-free bounded HTTPS request. DNS is resolved and pinned anew for
* every hop, so redirects cannot introduce a DNS-rebinding or private target.
*/
export async function boundedPublicHttpsRequest(
raw: string,
options: RequestOptions = {},
): Promise<BoundedHttpsResponse> {
const bounded: Required<RequestOptions> = {
method: options.method ?? "GET",
accept: options.accept ?? "application/json",
maxBytes: Math.max(0, Math.min(1024 * 1024, options.maxBytes ?? DEFAULT_MAX_BYTES)),
maxRedirects: Math.max(0, Math.min(5, options.maxRedirects ?? DEFAULT_MAX_REDIRECTS)),
timeoutMs: Math.max(250, Math.min(30_000, options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
};
const deadline = Date.now() + bounded.timeoutMs;
let current = await beforeDeadline(validatePublicHttpsUrl(raw), deadline);
for (let redirects = 0; redirects <= bounded.maxRedirects; redirects++) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error("whole-request timeout");
const response = await requestPinned(current.url, current.ip, { ...bounded, timeoutMs: remaining });
if (response.status < 300 || response.status >= 400) return response;
if (!response.location || redirects === bounded.maxRedirects) throw new Error("redirect limit exceeded");
current = await beforeDeadline(
validatePublicHttpsUrl(new URL(response.location, current.url).href),
deadline,
);
}
throw new Error("redirect limit exceeded");
}
10 changes: 10 additions & 0 deletions reference-implementations/dacs-directory/src/catalog/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export const listingSummarySchema = {
},
artifactProfile: { enum: artifactProfiles },
publicEndpoint: { type: "string", format: "uri" },
reachabilityHint: {
type: "object",
required: ["status", "checkedAt"],
additionalProperties: false,
properties: {
status: { enum: ["reachable", "unreachable", "unknown"] },
checkedAt: { type: "integer", minimum: 0 },
surface: { type: "string", format: "uri" },
},
},
offering: {
type: "object", required: ["title", "category", "tags"],
properties: {
Expand Down
115 changes: 115 additions & 0 deletions reference-implementations/dacs-directory/src/catalog/reachability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { boundedPublicHttpsRequest, OutboundTargetError } from "./boundedHttps.js";
import { safePublicEndpoint } from "./publicEndpoint.js";
import {
effectiveReachabilityStatus,
reachabilityHintIsFresh,
REACHABILITY_FRESH_MS,
} from "./reachabilityStatus.js";
import type { ListingSummary, ReachabilityHint, SellerRecord } from "./types.js";

export { effectiveReachabilityStatus, REACHABILITY_FRESH_MS };

const boundedInt = (value: unknown, fallback: number, min: number, max: number): number => {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
};

/** Probe only network reachability; an HTTP error response still proves reachability. */
export async function probeReachabilitySurface(surface: string): Promise<ReachabilityHint> {
const checkedAt = Date.now();
try {
const response = await boundedPublicHttpsRequest(surface, {
method: "HEAD",
accept: "*/*",
maxBytes: 1_024,
maxRedirects: 3,
timeoutMs: 5_000,
});
return {
status: response.status >= 100 && response.status <= 599 ? "reachable" : "unreachable",
checkedAt,
surface,
};
} catch (error) {
return {
// A target rejected by outbound policy was deliberately not contacted;
// do not claim it is unreachable or disclose internal network details.
status: error instanceof OutboundTargetError ? "unknown" : "unreachable",
checkedAt,
surface,
};
}
}

const listingKey = (seller: string, listing: ListingSummary): string =>
`${seller}\n${listing.listingId}\n${listing.version}\n${listing.contentHash}`;

export interface ReachabilityRefreshOptions {
now?: number;
cursor?: number;
maxProbes?: number;
concurrency?: number;
probe?: (surface: string) => Promise<ReachabilityHint>;
}

/**
* Attach independently-derived hints after all listing verification. Results
* never participate in listing inclusion, revocation, identity or reputation.
* Returns the round-robin cursor for the next bounded refresh pass.
*/
export async function refreshReachabilityHints(
sellers: SellerRecord[],
priorSellers: SellerRecord[],
options: ReachabilityRefreshOptions = {},
): Promise<number> {
const now = options.now ?? Date.now();
const maxProbes = boundedInt(options.maxProbes ?? process.env.DACS_REACHABILITY_MAX_PROBES, 20, 1, 100);
const concurrency = boundedInt(options.concurrency ?? process.env.DACS_REACHABILITY_CONCURRENCY, 5, 1, 10);
const probe = options.probe ?? probeReachabilitySurface;
const prior = new Map<string, ListingSummary>();
for (const seller of priorSellers) {
for (const listing of seller.listings) prior.set(listingKey(seller.primaryClaim, listing), listing);
}

const due: Array<{ key: string; listing: ListingSummary; surface: string }> = [];
for (const seller of sellers) {
for (const listing of seller.listings) {
const surface = safePublicEndpoint(listing.publicEndpoint);
if (!surface || listing.status !== "active") {
listing.reachabilityHint = undefined;
continue;
}
const previous = prior.get(listingKey(seller.primaryClaim, listing))?.reachabilityHint;
if (previous?.surface === surface && reachabilityHintIsFresh(previous, now)) {
listing.reachabilityHint = previous;
continue;
}
if (previous?.surface === surface) listing.reachabilityHint = { ...previous, status: "unknown" };
due.push({ key: listingKey(seller.primaryClaim, listing), listing, surface });
}
}
if (due.length === 0) return 0;
due.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
const start = boundedInt(options.cursor, 0, 0, Number.MAX_SAFE_INTEGER) % due.length;
const selected = Array.from({ length: Math.min(maxProbes, due.length) }, (_, index) => due[(start + index) % due.length]);
let next = 0;
const bySurface = new Map<string, Promise<ReachabilityHint>>();
const probeOnce = (surface: string): Promise<ReachabilityHint> => {
let pending = bySurface.get(surface);
if (!pending) {
pending = probe(surface);
bySurface.set(surface, pending);
}
return pending;
};
const workers = Array.from({ length: Math.min(concurrency, selected.length) }, async () => {
for (;;) {
const index = next++;
if (index >= selected.length) return;
const candidate = selected[index];
candidate.listing.reachabilityHint = await probeOnce(candidate.surface);
}
});
await Promise.all(workers);
return (start + selected.length) % due.length;
}
Loading