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
18 changes: 18 additions & 0 deletions .github/scripts/test_ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,24 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None:
self.assertIn("\n - relay-client-contracts\n", rust_result)
self.assertNotIn("\n - notary-contracts\n", rust_result)

def test_discovery_contracts_pins_node_for_adopter_binding_tests(self) -> None:
Comment thread
jeremi marked this conversation as resolved.
workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8")
discovery_job = workflow.split("\n discovery-contracts:\n", 1)[1].split(
"\n relay-v2-contracts:\n", 1
)[0]

self.assertIn(
"uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020",
discovery_job,
)
self.assertIn("node-version: 22.12.0", discovery_job)
self.assertIn("cache: npm", discovery_job)
self.assertIn(
"cache-dependency-path: "
"crates/registry-discovery-client-node/package-lock.json",
discovery_job,
)

def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None:
current_content = classify(
self.workspace,
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,13 @@ jobs:
persist-credentials: false
submodules: false

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 22.12.0
cache: npm
cache-dependency-path: crates/registry-discovery-client-node/package-lock.json

- name: Cache Cargo registry
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
with:
Expand Down
170 changes: 148 additions & 22 deletions crates/registry-discovery-client-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,63 +25,189 @@ binary.
```js
const {
DiscoveryClient,
acceptSelection,
renewUnchangedSelection,
selectEvidenceAlternative,
selectEvidenceService,
validateSelection,
validateSelectionStructure,
} = require('@registrystack/discovery-client');
const { EvidenceClient } = require('@registrystack/evidence-client');

// These exact pins are application configuration. They are not copied from
// Discovery metadata and do not define a Discovery-owned trust-store schema.
const expectedEvidence = Object.freeze({
serviceKind: 'evidence',
serviceId: 'urn:example:service:evidence',
endpointUrl: 'https://evidence.example.invalid/',
legalIssuerId: 'urn:example:issuer',
technicalProviderId: 'urn:example:provider',
conformsTo: ['urn:example:evidence-profile'],
jurisdictions: ['urn:example:jurisdiction'],
evidenceTypeIds: ['urn:example:evidence-type'],
matchedCapability: {
kind: 'evidence-type',
id: 'urn:example:evidence-type',
},
evidenceResolution: {
requirementId: 'urn:example:requirement',
jurisdiction: 'urn:example:jurisdiction',
mappingRevision: `sha256:${'1'.repeat(64)}`,
evidenceTypeListId: 'urn:example:evidence-type-list',
evidenceTypeIds: ['urn:example:evidence-type'],
mappingId: 'urn:example:mapping',
mappingAuthorityId: 'urn:example:mapping-authority',
},
});

function sameOrderedStrings(actual, expected) {
return Array.isArray(actual)
&& actual.length === expected.length
&& actual.every((value, index) => value === expected[index]);
}

function acceptsExpectedEvidence(candidate) {
const actualResolution = candidate.evidenceResolution;
const expectedResolution = expectedEvidence.evidenceResolution;
return candidate.serviceKind === expectedEvidence.serviceKind
&& candidate.serviceId === expectedEvidence.serviceId
&& candidate.endpointUrl === expectedEvidence.endpointUrl
&& candidate.legalIssuerId === expectedEvidence.legalIssuerId
&& candidate.technicalProviderId === expectedEvidence.technicalProviderId
&& sameOrderedStrings(candidate.conformsTo, expectedEvidence.conformsTo)
&& sameOrderedStrings(candidate.jurisdictions, expectedEvidence.jurisdictions)
&& sameOrderedStrings(candidate.evidenceTypeIds, expectedEvidence.evidenceTypeIds)
&& candidate.matchedCapability.kind === expectedEvidence.matchedCapability.kind
&& candidate.matchedCapability.id === expectedEvidence.matchedCapability.id
&& actualResolution !== undefined
&& actualResolution.requirementId === expectedResolution.requirementId
&& actualResolution.jurisdiction === expectedResolution.jurisdiction
&& actualResolution.mappingRevision === expectedResolution.mappingRevision
&& actualResolution.evidenceTypeListId === expectedResolution.evidenceTypeListId
&& sameOrderedStrings(actualResolution.evidenceTypeIds, expectedResolution.evidenceTypeIds)
&& actualResolution.mappingId === expectedResolution.mappingId
&& actualResolution.mappingAuthorityId === expectedResolution.mappingAuthorityId;
}

const client = new DiscoveryClient('https://discovery.example.invalid/');
const resolved = await client.resolveEvidenceTypes({
requirementId: 'urn:example:requirement',
jurisdiction: 'urn:example:jurisdiction',
requirementId: expectedEvidence.evidenceResolution.requirementId,
jurisdiction: expectedEvidence.evidenceResolution.jurisdiction,
});
const context = selectEvidenceAlternative(resolved); // refuses zero or many alternatives
const context = selectEvidenceAlternative(
resolved,
expectedEvidence.evidenceResolution.evidenceTypeListId,
); // explicit choice; refuses an absent or duplicate alternative
for (const evidenceTypeId of context.evidenceTypeIds) {
const services = await client.searchEvidenceServices({
evidenceTypeId,
serviceIds: [expectedEvidence.serviceId],
...(context.jurisdiction ? { jurisdiction: context.jurisdiction } : {}),
});
const chosen = await adopterChooseRecord(services.items); // no catalog ranking is implied
// The application explicitly chooses a record. Discovery defines no rank.
const chosen = services.items.find(
(item) => item.serviceId === expectedEvidence.serviceId,
);
Comment on lines +107 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject ambiguous service-ID matches in the Node example

When two origins advertise the same serviceId—which the index deliberately preserves as distinct records—this filtered result can contain multiple items, but .find silently chooses whichever record appears first despite the comment promising an explicit choice. Because the sample acceptance policy also omits publisherId, originId, and originUrl, otherwise identical advertisements from different origins can both pass and the adopter proceeds to credential construction and native I/O without reviewing which record was selected. Require exactly one independently pinned match or make the record/origin choice explicit.

AGENTS.md reference: AGENTS.md:L75-L79

Useful? React with 👍 / 👎.

if (!chosen) throw new Error('expected Evidence service is unavailable');
const selection = selectEvidenceService(services, {
recordId: chosen.recordId,
evidenceTypeId,
resolution: context,
});

const checked = validateSelection(selection); // use after loading a persisted selection
appTrust.requireEvidence(checked); // local pins, never Discovery data
// Structural validation checks closed shape and capability binding only.
const structurallyValid = validateSelectionStructure(selection);
const accepted = acceptSelection(structurallyValid, acceptsExpectedEvidence);

// Create credentials and the native client only after local acceptance.
const evidence = new EvidenceClient({
baseUrl: checked.endpointUrl,
baseUrl: accepted.endpointUrl,
trustedJwks,
revokedKeyIds,
token,
});
if (!checked.evidenceResolution) throw new Error('missing Evidence resolution');
const acceptedSelection = accepted.selection;
if (!acceptedSelection.evidenceResolution) throw new Error('missing Evidence resolution');
const prepared = evidence.prepare({
...localEvidencePolicy,
requirement: checked.evidenceResolution.requirementId,
evidenceType: checked.matchedCapability.id,
requirement: acceptedSelection.evidenceResolution.requirementId,
evidenceType: acceptedSelection.matchedCapability.id,
});
const verified = await evidence.requestAndVerify(prepared);
}
```

An Evidence alternative is an AND-list. The loop performs the search, explicit
choice, trust check, and native request for every `context.evidenceTypeIds`
member.
The sample pins a one-member Evidence alternative. An alternative may be an
AND-list; in that case, configure an expected policy for every
`context.evidenceTypeIds` member and perform the search, explicit choice,
acceptance, and native request for each member.
The context supplies the resolved `requirementId` and selected Evidence Type;
the native definition and local policy still supply purpose, audience,
issuer/provider identity, configuration revision, selectors, and expected
outputs.

Relay follows the same boundary with `searchRelayServices` and
`selectRelayService`. The selection retains both `semanticClassId` and
`operationFamilyId`; after `appTrust.requireRelay(selection)`, pass
`selection.endpointUrl` to `new RelayClient({ baseUrl, authorization })` and
use Relay's native metadata to choose the concrete resource and operation.
Discovery never invents Relay route arguments.

Persist the plain selection object if useful, then call `validateSelection`
after loading it. Never treat its origin, endpoint, issuer, or capability
claims as trusted solely because Discovery returned them.
`operationFamilyId`. Its adopter callback should exact-pin `serviceKind`,
`serviceId`, `endpointUrl`, `operatorId`, `registryAuthorityId`, `conformsTo`,
`jurisdictions`, `matchedCapability`, and `relayCapabilityMatch`. Pass
`acceptSelection(selection, acceptsExpectedRelay).endpointUrl` to
`new RelayClient({ baseUrl, authorization })`, constructing `authorization`
only after acceptance. Use Relay's native metadata to choose the concrete
resource and operation. Discovery never invents Relay route arguments.

## Persisting and renewing a selection

Persist only the plain selection object. `AcceptedServiceSelection` is an
ephemeral handoff and must be recreated under current local policy before each
new native client or credential-bearing session. Loading a saved selection and
calling `validateSelectionStructure` proves only structural validity. It does
not prove that the catalog, mapping, endpoint, authority, or local policy is
current.

Online renewal means re-resolving the requirement, re-searching, explicitly
reselecting the record, checking that its trust-relevant semantics are
unchanged, and applying local acceptance again:

```js
async function renewEvidenceSelection(saved) {
const previous = validateSelectionStructure(saved);
if (!previous.evidenceResolution) throw new Error('missing Evidence resolution');

const resolved = await client.resolveEvidenceTypes({
requirementId: previous.evidenceResolution.requirementId,
...(previous.evidenceResolution.jurisdiction
? { jurisdiction: previous.evidenceResolution.jurisdiction }
: {}),
});
const currentContext = selectEvidenceAlternative(
resolved,
previous.evidenceResolution.evidenceTypeListId,
);
const currentServices = await client.searchEvidenceServices({
evidenceTypeId: previous.matchedCapability.id,
serviceIds: [previous.serviceId],
...(currentContext.jurisdiction ? { jurisdiction: currentContext.jurisdiction } : {}),
});
const current = selectEvidenceService(currentServices, {
recordId: previous.recordId,
evidenceTypeId: previous.matchedCapability.id,
resolution: currentContext,
});

// Refuses withdrawal or any trust-relevant semantic change. It never
// silently chooses a replacement service or Evidence alternative.
const renewed = renewUnchangedSelection(previous, current);
return acceptSelection(renewed, acceptsExpectedEvidence);
}
```

An application may deliberately use a saved selection while offline, but it is
then only structurally valid and subject to the application's own maximum-age
and currentness policy. Never treat origin, endpoint, issuer, provider,
authority, or capability claims as trusted solely because Discovery returned
them.

This boundary preserves
[ADR-001](../../products/discovery/DECISIONS.md#adr-001-discovery-is-an-index-not-a-trust-or-invocation-layer):
Discovery remains an index and neither defines adopter trust policy nor proxies
native Evidence or Relay invocation.
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import {
AcceptedServiceSelection,
DiscoveryClient,
acceptSelection,
renewUnchangedSelection,
selectEvidenceAlternative,
selectEvidenceService,
selectRelayService,
validateSelection,
validateSelectionStructure,
type EvidenceServiceSelection,
type RelayServiceSelection,
type ServiceRecord,
Expand All @@ -25,7 +29,20 @@ async function useDiscoveryClient(): Promise<void> {
resolution: context,
});
expectType<string>(selection.originContentDigest);
expectType<EvidenceServiceSelection>(validateSelectionStructure(selection));
expectType<EvidenceServiceSelection>(validateSelection(selection));
const accepted = acceptSelection(selection, (candidate) => candidate.serviceKind === 'evidence');
expectType<AcceptedServiceSelection<EvidenceServiceSelection>>(accepted);
expectType<string>(accepted.endpointUrl);
expectType<EvidenceServiceSelection>(accepted.selection);
expectType<EvidenceServiceSelection>(renewUnchangedSelection(selection, selection));

// @ts-expect-error Only acceptSelection can construct the accepted handoff.
const forged: AcceptedServiceSelection<EvidenceServiceSelection> = {
endpointUrl: selection.endpointUrl,
selection,
};
void forged;

const relayResponse = await client.searchRelayServices({
semanticClassId: 'urn:example:business',
Expand Down
Loading