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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] — 2026-04-24

### Changed (BREAKING)

- `matchQuery` now surfaces the specific `UnmatchedReason` (`format_mismatch`, `vct_mismatch`, `doctype_mismatch`, `missing_claims`, `value_mismatch`, `trusted_authority_mismatch`) for every unmatched entry instead of collapsing every failure to `'no_credential_found'`. `'no_credential_found'` is now reserved for the case where the credential list passed to `matchQuery` is empty.
- Migration: callers asserting `reason === 'no_credential_found'` on non-empty credential inputs should assert on the specific reason (see the union above) or on `!match.satisfied`.

## [0.1.1] — 2026-04-19

### Changed
Expand All @@ -25,5 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Interop test vectors from OpenID4VP 1.0 spec and the OpenWallet Foundation Animo reference suite.
- Coverage enforcement at ≥95% lines / ≥90% branches.

[0.2.0]: https://github.com/openeudi/dcql/releases/tag/v0.2.0
[0.1.1]: https://github.com/openeudi/dcql/releases/tag/v0.1.1
[0.1.0]: https://github.com/openeudi/dcql/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openeudi/dcql",
"version": "0.1.1",
"version": "0.2.0",
"description": "DCQL (Digital Credentials Query Language) query validation and credential matching for OpenID4VP 1.0",
"license": "Apache-2.0",
"type": "module",
Expand Down
36 changes: 27 additions & 9 deletions src/match/query.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import type { DcqlQuery, DcqlMatchResult, DecodedCredential } from '../types.js';
import type { DcqlQuery, DcqlMatchResult, DecodedCredential, UnmatchedReason } from '../types.js';

import { isCredentialSetSatisfied } from './assignment.js';
import { matchCredentialQuery } from './credential.js';

/**
* Match a DCQL query against a set of decoded credentials.
*
* Returns `{ satisfied, matches, unmatched }`. Each `unmatched` entry surfaces
* the specific {@link UnmatchedReason} from the last credential attempted
* against that query (`format_mismatch`, `vct_mismatch`, `doctype_mismatch`,
* `missing_claims`, `value_mismatch`, or `trusted_authority_mismatch`), plus
* a JSON-pointer `detail` when the failure targets a specific claim path.
*
* `'no_credential_found'` is reserved for the case where the credential list
* is empty — no candidates were attempted, so no specific reason exists.
*
* When multiple candidates fail against the same query, the reason and detail
* reflect the LAST credential attempted. DCQL does not specify credential
* ordering, so callers treating `reason` as a primary failure classifier
* should not rely on which candidate "won" the diagnostic.
*/
export function matchQuery(query: DcqlQuery, credentials: DecodedCredential[]): DcqlMatchResult {
const matches: DcqlMatchResult['matches'] = [];
const unmatched: DcqlMatchResult['unmatched'] = [];
Expand All @@ -13,25 +30,26 @@ export function matchQuery(query: DcqlQuery, credentials: DecodedCredential[]):
credential: DecodedCredential;
extractedClaims: Record<string, unknown>;
}> = [];
let lastDetail: string | undefined;
let lastFailure: { reason: UnmatchedReason; detail?: string } | undefined;

for (const cred of credentials) {
const r = matchCredentialQuery(cq, cred);
if (r.matched) {
candidates.push({ credential: cred, extractedClaims: r.extractedClaims });
if (cq.multiple !== true) break;
} else {
lastDetail = r.detail;
lastFailure = r.detail !== undefined
? { reason: r.reason, detail: r.detail }
: { reason: r.reason };
}
}

if (candidates.length === 0) {
// Always surface 'no_credential_found' at the query level — callers
// should not need to distinguish *why* a specific credential failed.
const entry: DcqlMatchResult['unmatched'][number] =
lastDetail !== undefined
? { queryId: cq.id, reason: 'no_credential_found', detail: lastDetail }
: { queryId: cq.id, reason: 'no_credential_found' };
const entry: DcqlMatchResult['unmatched'][number] = lastFailure
? lastFailure.detail !== undefined
? { queryId: cq.id, reason: lastFailure.reason, detail: lastFailure.detail }
: { queryId: cq.id, reason: lastFailure.reason }
: { queryId: cq.id, reason: 'no_credential_found' };
unmatched.push(entry);
continue;
}
Expand Down
54 changes: 50 additions & 4 deletions tests/match.query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ describe('matchQuery — simple cases', () => {
expect(r.unmatched).toHaveLength(0);
});

it('reports unmatched when no credential satisfies', () => {
it('reports format_mismatch when every candidate fails the format check', () => {
const q: DcqlQuery = {
credentials: [{ id: 'c1', format: 'jwt_vc_json' }],
};
const r = matchQuery(q, [pid]);
expect(r.satisfied).toBe(false);
expect(r.unmatched[0]?.queryId).toBe('c1');
expect(r.unmatched[0]?.reason).toBe('no_credential_found');
expect(r.unmatched[0]?.reason).toBe('format_mismatch');
});

it('includes detail in unmatched entry when credential fails with details', () => {
it('reports missing_claims with claim-path detail when required claim absent', () => {
const q: DcqlQuery = {
credentials: [
{
Expand All @@ -59,7 +59,42 @@ describe('matchQuery — simple cases', () => {
const r = matchQuery(q, [pid]);
expect(r.satisfied).toBe(false);
expect(r.unmatched[0]?.queryId).toBe('c1');
expect(r.unmatched[0]?.detail).toBeDefined();
expect(r.unmatched[0]?.reason).toBe('missing_claims');
expect(r.unmatched[0]?.detail).toBe('/missing_field');
});

it('reports value_mismatch when claim present but values filter excludes', () => {
// `pid` has family_name: 'Doe'. Query demands family_name in ['Smith'] → excluded.
const q: DcqlQuery = {
credentials: [
{
id: 'c1',
format: 'dc+sd-jwt',
meta: { vct_values: ['urn:eu.europa.ec.eudi:pid:1'] },
claims: [{ path: ['family_name'], values: ['Smith'] }],
},
],
};
const r = matchQuery(q, [pid]);
expect(r.satisfied).toBe(false);
expect(r.unmatched[0]?.reason).toBe('value_mismatch');
expect(r.unmatched[0]?.detail).toBe('/family_name');
});

it('satisfies when claim present AND values filter includes', () => {
const q: DcqlQuery = {
credentials: [
{
id: 'c1',
format: 'dc+sd-jwt',
meta: { vct_values: ['urn:eu.europa.ec.eudi:pid:1'] },
claims: [{ path: ['family_name'], values: ['Doe', 'Smith'] }],
},
],
};
const r = matchQuery(q, [pid]);
expect(r.satisfied).toBe(true);
expect(r.matches[0]?.extractedClaims).toEqual({ family_name: 'Doe' });
});

it('requires all CredentialQueries without credential_sets', () => {
Expand Down Expand Up @@ -94,6 +129,17 @@ describe('matchQuery — simple cases', () => {
expect(matchQuery(q, [a, b]).matches[0]?.credentialId).toBe('A');
expect(matchQuery(q, [b, a]).matches[0]?.credentialId).toBe('B');
});

it('reports no_credential_found when the credential list is empty', () => {
const q: DcqlQuery = {
credentials: [{ id: 'c1', format: 'dc+sd-jwt' }],
};
const r = matchQuery(q, []);
expect(r.satisfied).toBe(false);
expect(r.unmatched[0]?.queryId).toBe('c1');
expect(r.unmatched[0]?.reason).toBe('no_credential_found');
expect(r.unmatched[0]?.detail).toBeUndefined();
});
});

describe('matchQuery — credential_sets', () => {
Expand Down
Loading