Skip to content

Conversation

@rvlb
Copy link
Contributor

@rvlb rvlb commented Nov 14, 2025

Summary by Sourcery

Add support for Directory-based issuer metadata by introducing a Directory class, extending SHCReader and SHC classes to propagate issuerInfo, and updating configuration and APIs accordingly

New Features:

  • Add Directory class to fetch and store issuer metadata (keys and CRLs) from given URLs or from a directory JSON
  • Extend SHCReader to accept a directory and attach issuerInfo to verified health cards
  • Update SHC and SHCIssuer APIs to propagate issuerInfo in issued and verified cards
  • Introduce Issuer type and directory config parameter in SHCReaderConfig

Tests:

  • Add test case to verify bundling of issuerInfo when reading JWS with a directory
  • Add test file for Directory functionality, validating the fromJSON and fromURLs static methods

Summary by Sourcery

Introduce support for directory-based issuer metadata by adding a Directory class, updating SHCIssuer, SHCReader, and SHC to propagate issuerInfo, defining related types, and adding tests for the new functionality

New Features:

  • Add Directory class to fetch and store issuer metadata (keys and CRLs) from given URLs or JSON
  • Enable SHCReader to accept a Directory instance and attach issuerInfo to verified health cards

Enhancements:

  • Extend SHCIssuer.issue, SHCReader.fromJWS, and SHC constructors to propagate issuerInfo through issued and verified SHC objects
  • Introduce Issuer, IssuerKey, IssuerCrl, and DirectoryJSON types to represent directory-based metadata
  • Bump Node engine requirement to >=20.0.0

Tests:

  • Add tests for Directory.fromJSON and Directory.fromURLs methods
  • Add test to verify bundling of issuerInfo when reading JWS with a Directory

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 14, 2025

Reviewer's Guide

This PR introduces directory-based issuer metadata support by implementing a Directory class to fetch and store issuer keys and CRLs, extending the SHCReader and SHCIssuer/SHC classes to propagate issuerInfo, adding new types and configuration parameters, updating tests accordingly, and bumping the Node engine requirement.

Sequence diagram for reading a JWS with directory-based issuer metadata

sequenceDiagram
  participant Reader as SHCReader
  participant Directory
  participant SHC
  Reader->>Directory: getIssuerInfo()
  Directory-->>Reader: Issuer[]
  Reader->>SHC: new SHC(jws, originalBundle, issuerInfo)
  SHC-->>Reader: SHC instance
Loading

Sequence diagram for issuing a health card with issuerInfo propagation

sequenceDiagram
  participant Issuer as SHCIssuer
  participant SHC
  Issuer->>SHC: new SHC(jws, fhirBundle, issuerInfo)
  SHC-->>Issuer: SHC instance
Loading

Class diagram for new and updated issuer metadata classes

classDiagram
  class Directory {
    - issuerInfo: Issuer[]
    + getIssuerInfo(): Issuer[]
    + static fromJSON(dirJson: DirectoryJSON): Directory
    + static fromURLs(issUrls: string[]): Promise<Directory>
  }
  class SHC {
    - jws: string
    - originalBundle: FHIRBundle
    - issuerInfo: Issuer[]
    + getIssuerInfo(): Issuer[]
  }
  class SHCReader {
    - config: SHCReaderConfig
    + read(jws: string): SHC
  }
  class SHCIssuer {
    + issue(fhirBundle: FHIRBundle, config: VerifiableCredentialParams, issuerInfo: Issuer[]): Promise<SHC>
  }
  class Issuer {
    + iss: string
    + keys: IssuerKey[]
    + crls: IssuerCrl[]
  }
  class IssuerKey {
    + kty: string
    + kid: string
  }
  class IssuerCrl {
    + kid: string
    + method: string
    + ctr: number
    + rids: string[]
  }
  class DirectoryJSON {
    + issuerInfo: issuerInfo[]
  }
  Directory --> Issuer
  SHC --> Issuer
  SHCReader --> Directory
  SHCIssuer --> SHC
  Issuer --> IssuerKey
  Issuer --> IssuerCrl
  DirectoryJSON --> Issuer
Loading

File-Level Changes

Change Details Files
Directory-based issuer metadata support
  • Implement Directory class with fromJSON and fromURLs to fetch issuer keys and CRLs
  • Integrate directory parameter into SHCReader config and retrieve issuerInfo during JWS verification
src/shc/directory.ts
src/shc/reader.ts
Propagate issuerInfo through issuer and health card APIs
  • Extend SHCIssuer.issue signature to accept and forward issuerInfo to SHC
  • Update SHC constructor to store issuerInfo and expose getIssuerInfo method
src/shc/issuer.ts
src/shc/shc.ts
Add Issuer-related types and configuration option
  • Define Issuer, IssuerKey, IssuerCrl, and DirectoryJSON types in types.ts
  • Add optional directory field to SHCReaderConfigParams
src/shc/types.ts
Add tests for Directory and issuerInfo bundling
  • Add SHCReader directory bundling test in shc.test.ts
  • Create directory.test.ts to validate Directory.fromJSON and fromURLs
test/shc/shc.test.ts
test/shc/directory.test.ts
Update Node engine requirement
  • Bump Node.js version requirement from >=18.0.0 to >=20.0.0
package.json

Possibly linked issues

  • #SHC VCI Directory support: The PR implements the Directory class and integrates it for VCI Directory support in SHC.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@vercel
Copy link

vercel bot commented Nov 14, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
medplum-shl Ready Ready Preview Comment Nov 19, 2025 0:34am

@github-actions
Copy link

github-actions bot commented Nov 14, 2025

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 95.87% 1931 / 2014
🔵 Statements 95.87% 1931 / 2014
🔵 Functions 98.49% 131 / 133
🔵 Branches 90.2% 608 / 674
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/shc/directory.ts 100% 100% 100% 100%
src/shc/index.ts 100% 100% 100% 100%
src/shc/issuer.ts 100% 100% 100% 100%
src/shc/reader.ts 93.89% 73.91% 100% 93.89% 180-181, 183-184, 198-199, 204-205
src/shc/shc.ts 100% 100% 100% 100%
src/shc/types.ts 0% 0% 0% 0%
Generated in workflow #103 for commit 5e7c85e by the Vitest Coverage Report Action

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `src/shc/directory.ts:20-29` </location>
<code_context>
+    try {
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Catching errors inside the loop may mask partial failures.

Move the try/catch block inside the loop so that errors from individual issuers don't prevent processing the rest, and to improve error reporting for each issuer.

Suggested implementation:

```typescript
    for (const issUrl of issUrls) {
      try {
        const issuer: Issuer = {
          iss: issUrl,
          keys: [],
          crls: [],
        }

        const crls = []
        const jwksUrl = `${issUrl}/.well-known/jwks.json`
        const jwksResponse = await fetch(jwksUrl)

```

```typescript
    try {
      for (const issUrl of issUrls) {
        const issuer: Issuer = {
          iss: issUrl,
          keys: [],
          crls: [],
        }

        const crls = []
        const jwksUrl = `${issUrl}/.well-known/jwks.json`
        const jwksResponse = await fetch(jwksUrl)
      } catch (error) {
        console.error(`Error processing issuer ${issUrl}:`, error)
        // Optionally, you could continue or add partial issuer info to issuersInfo
        continue
      }
    }

```
</issue_to_address>

### Comment 2
<location> `src/shc/directory.ts:29-31` </location>
<code_context>
+        const jwksUrl = `${issUrl}/.well-known/jwks.json`
+        const jwksResponse = await fetch(jwksUrl)
+        if (!jwksResponse.ok) {
+          const errorData = await jwksResponse.json().catch(() => ({ error: 'Unknown error' }))
+          throw new Error(errorData.error || `Failed to fetch jwks at ${jwksUrl}`)
+        }
</code_context>

<issue_to_address>
**suggestion:** Parsing error responses as JSON may not always be reliable.

Some endpoints may return non-JSON errors, leading to unexpected exceptions. Consider verifying the content-type header or providing a fallback error message if JSON parsing fails.

```suggestion
        const jwksUrl = `${issUrl}/.well-known/jwks.json`
        const jwksResponse = await fetch(jwksUrl)
        if (!jwksResponse.ok) {
          let errorMessage: string
          const contentType = jwksResponse.headers.get('content-type') || ''
          if (contentType.includes('application/json')) {
            try {
              const errorData = await jwksResponse.json()
              errorMessage = errorData.error || `Failed to fetch jwks at ${jwksUrl}`
            } catch {
              errorMessage = `Failed to fetch jwks at ${jwksUrl} (invalid JSON error response)`
            }
          } else {
            errorMessage = `Failed to fetch jwks at ${jwksUrl} (non-JSON error response)`
          }
          throw new Error(errorMessage)
        }
```
</issue_to_address>

### Comment 3
<location> `src/shc/directory.ts:47` </location>
<code_context>
+            continue
+          }
+          const crl = await crlResponse.json()
+          if (crl) crls.push(crl)
+        }
+
</code_context>

<issue_to_address>
**suggestion (bug_risk):** No validation of CRL structure before adding to array.

Malformed or incomplete CRLs may be included. Please validate the CRL object to ensure it meets expected structure before adding it to the array.

Suggested implementation:

```typescript
        // Helper function to validate CRL structure
        function isValidCrl(crl: any): boolean {
          // Example: require 'id' and 'revoked' fields
          return (
            typeof crl === 'object' &&
            crl !== null &&
            typeof crl.id === 'string' &&
            Array.isArray(crl.revoked)
          )
        }

        for (const key of issKeys) {
          const crlUrl = `${issUrl}/.well-known/crl/${key.kid}.json`
          const crlResponse = await fetch(crlUrl)
          if (!crlResponse.ok) {
            console.debug(
              `Failed to fetch crl at ${crlUrl} with status ${crlResponse.status}, skipping.`
            )
            continue
          }
          const crl = await crlResponse.json()
          if (isValidCrl(crl)) {
            crls.push(crl)
          } else {
            console.warn(`Invalid CRL structure from ${crlUrl}, skipping.`)
          }

```

You may need to adjust the `isValidCrl` function to match the exact expected CRL structure for your application. Add or remove required fields as necessary.
</issue_to_address>

### Comment 4
<location> `src/shc/directory.ts:11-13` </location>
<code_context>
+    return this.issuerInfo
+  }
+
+  static fromJSON(dirJson: DirectoryJSON): Directory {
+    const data: Issuer[] = dirJson.issuerInfo.map(({ issuer: { iss }, keys, crls }) => {
+      return { iss, keys, crls } as Issuer
</code_context>

<issue_to_address>
**suggestion:** Type assertion with 'as Issuer' may mask type mismatches.

Explicitly validate the input data or assign properties to ensure type safety instead of relying on type assertion.

```suggestion
    const data: Issuer[] = dirJson.issuerInfo.map(({ issuer, keys, crls }) => {
      // Validate and assign properties explicitly
      const iss = typeof issuer?.iss === 'string' ? issuer.iss : '';
      const validKeys = Array.isArray(keys) ? keys : [];
      const validCrls = Array.isArray(crls) ? crls : [];
      return {
        iss,
        keys: validKeys,
        crls: validCrls
      };
    })
```
</issue_to_address>

### Comment 5
<location> `test/shc/directory.test.ts:4` </location>
<code_context>
+import type { DirectoryJSON, Issuer } from './types'
+
+export class Directory {
+  constructor(private issuerInfo: Issuer[]) {}
</code_context>

<issue_to_address>
**suggestion (testing):** Missing test cases for error handling in Directory.fromURLs.

Add tests for Directory.fromURLs to cover fetch failures, such as network errors, invalid responses, and malformed JSON, ensuring proper error handling and logging or exception behavior.
</issue_to_address>

### Comment 6
<location> `test/shc/shc.test.ts:61-70` </location>
<code_context>
+    it('should bundle issuerInfo into SHC when reader created with a directory', async () => {
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding negative and edge case tests for issuerInfo propagation.

Please include tests for empty directories, multiple issuers, and missing keys/CRLs to verify SHCReader and SHC behavior in these scenarios.

Suggested implementation:

```typescript
    it('should bundle issuerInfo into SHC when reader created with a directory', async () => {
      const { importPKCS8, importSPKI } = await import('jose')

      const privateKeyCrypto = await importPKCS8(testPrivateKeyPKCS8, 'ES256')
      const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256')

      const configWithCryptoKeys: SHCConfig = {
        issuer: 'https://example.com/issuer',
        privateKey: privateKeyCrypto,
        publicKey: publicKeyCrypto,
        expirationTime: null,
      }
      // ... existing test logic ...
    })

    it('should not bundle issuerInfo when reader created with an empty directory', async () => {
      const emptyDirectory = new Directory([])
      const reader = new SHCReader({ directory: emptyDirectory })
      const shc = await reader.createSHC(createValidFHIRBundle())
      expect(shc.issuerInfo).toBeUndefined()
    })

    it('should bundle issuerInfo for multiple issuers in directory', async () => {
      const { importPKCS8, importSPKI } = await import('jose')
      const privateKeyCrypto1 = await importPKCS8(testPrivateKeyPKCS8, 'ES256')
      const publicKeyCrypto1 = await importSPKI(testPublicKeySPKI, 'ES256')
      const privateKeyCrypto2 = await importPKCS8(testPrivateKeyPKCS8_2, 'ES256')
      const publicKeyCrypto2 = await importSPKI(testPublicKeySPKI_2, 'ES256')

      const directory = new Directory([
        {
          issuer: 'https://issuer1.com',
          publicKey: publicKeyCrypto1,
          crl: ['crl1'],
        },
        {
          issuer: 'https://issuer2.com',
          publicKey: publicKeyCrypto2,
          crl: ['crl2'],
        },
      ])
      const reader = new SHCReader({ directory })
      const shc = await reader.createSHC(createValidFHIRBundle())
      expect(shc.issuerInfo).toBeDefined()
      expect(Array.isArray(shc.issuerInfo)).toBe(true)
      expect(shc.issuerInfo.length).toBe(2)
      expect(shc.issuerInfo[0].issuer).toBe('https://issuer1.com')
      expect(shc.issuerInfo[1].issuer).toBe('https://issuer2.com')
    })

    it('should handle missing publicKey in directory entry', async () => {
      const directory = new Directory([
        {
          issuer: 'https://issuer-missing-key.com',
          // publicKey is missing
          crl: ['crl1'],
        },
      ])
      const reader = new SHCReader({ directory })
      await expect(reader.createSHC(createValidFHIRBundle())).rejects.toThrow(/publicKey/i)
    })

    it('should handle missing CRL in directory entry', async () => {
      const { importSPKI } = await import('jose')
      const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256')
      const directory = new Directory([
        {
          issuer: 'https://issuer-missing-crl.com',
          publicKey: publicKeyCrypto,
          // crl is missing
        },
      ])
      const reader = new SHCReader({ directory })
      const shc = await reader.createSHC(createValidFHIRBundle())
      expect(shc.issuerInfo[0].crl).toBeUndefined()
    })

```

- You may need to define `testPrivateKeyPKCS8_2` and `testPublicKeySPKI_2` for the multiple issuers test, or mock them similarly to your existing test keys.
- Adjust error handling in your SHCReader implementation if it does not currently throw on missing keys.
- Ensure that the Directory and SHCReader classes support these edge cases as expected.
</issue_to_address>

### Comment 7
<location> `test/shc/shc.test.ts:123-59` </location>
<code_context>
+      })
+
+      const verifiedHealthCard = await readerWithDirectory.fromJWS(jws)
+      expect(verifiedHealthCard.getIssuerInfo()).toBe(directory.getIssuerInfo())
+    })
+
     it('should issue SMART Health Card with CryptoKey objects', async () => {
</code_context>

<issue_to_address>
**nitpick (testing):** Nitpick: Use deep equality for issuerInfo comparison.

Use 'toEqual' instead of 'toBe' to compare issuerInfo objects, ensuring deep equality rather than reference equality.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@vintasoftware vintasoftware deleted a comment from sourcery-ai bot Nov 14, 2025
@vintasoftware vintasoftware deleted a comment from sourcery-ai bot Nov 14, 2025
@vintasoftware vintasoftware deleted a comment from sourcery-ai bot Nov 14, 2025
@vintasoftware vintasoftware deleted a comment from sourcery-ai bot Nov 14, 2025
Copy link
Member

@fjsj fjsj left a comment

Choose a reason for hiding this comment

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

Please check comments and add documentation.

Copy link
Member

@fjsj fjsj left a comment

Choose a reason for hiding this comment

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

LGTM, but check the two suggested minor changes

@fjsj fjsj merged commit d8aed9d into main Nov 24, 2025
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants