Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,25 @@ console.log('Resolved FHIR resources:', resolved.fhirResources);

This example above demonstrates the complete lifecycle: SHL generation, content addition, manifest builder persistence in server-side database, manifest serving, and client-side resolution. In a real application, you would implement persistence for the manifest builder state and serve the manifest endpoint from your backend server.

### Directories
#### Building a Directory from VCI Snapshot
`Directory.fromVCI` is a convenience helper that fetches the VCI Directory snapshot published by The Commons Project and returns a Directory instance built from that snapshot.

Use it when you want a quick, canonical directory of issuer metadata (JWKS + CRLs) without manually assembling or maintaining a directory JSON.

```typescript
import { Directory } from 'kill-the-clipboard'

// top-level async context or inside an async function
try {
const directory = await Directory.fromVCI()
const issuers = directory.getIssuerInfo()
console.log('Loaded issuers:', issuers.length)
} catch (err) {
console.error('Failed to load VCI Directory:', err)
}
```

### Error Handling

The library provides granular error handling with specific error codes for different failure scenarios. Check each method documentation for the specific errors that can be thrown.
Expand Down
26 changes: 26 additions & 0 deletions src/shc/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ export class Directory {
return this.issuerInfo
}

/**
* Fetch a snapshot of the VCI Directory published by The Commons Project
* and build a {@link Directory} from it.
*
* This helper fetches a well-known VCI snapshot JSON file and delegates to
* `Directory.fromJSON` to produce a `Directory` instance. If the snapshot
* cannot be retrieved (non-2xx response) the function throws an Error.
*
* @returns A {@link Directory} populated from the VCI snapshot
* @throws Error when the VCI snapshot HTTP fetch returns a non-OK status
* @example
* const directory = await Directory.fromVCI()
*/
static async fromVCI(): Promise<Directory> {
const vciSnapshotResponse = await fetch(
'https://raw.githubusercontent.com/the-commons-project/vci-directory/main/logs/vci_snapshot.json'
)
if (!vciSnapshotResponse.ok) {
throw new Error(
`Failed to fetch VCI Directory snapshot with status ${vciSnapshotResponse.status}`
)
}
const vciDirectoryJson = await vciSnapshotResponse.json()
return Directory.fromJSON(vciDirectoryJson)
}

/**
* Build a Directory from a parsed JSON object matching the published
* directory schema.
Expand Down
228 changes: 135 additions & 93 deletions test/shc/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,112 +2,154 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Directory } from '../../src/shc/directory'
import type { DirectoryJSON } from '../../src/shc/types'

describe('Directory', () => {
const ISS_URL = 'https://example.com/issuer'

afterEach(() => {
vi.restoreAllMocks()
})

it('should create a directory from JSON', () => {
const directoryJson = {
directory: 'https://example.com/keystore/directory.json',
issuerInfo: [
const SAMPLE_DIRECTORY_JSON = {
directory: 'https://example.com/keystore/directory.json',
issuerInfo: [
{
issuer: {
iss: 'https://example.com/issuer',
name: 'Example Issuer 1',
},
keys: [
{
issuer: {
iss: 'https://example.com/issuer',
name: 'Example Issuer 1',
},
keys: [
{
kty: 'EC',
kid: 'kid-1-simple',
},
{
kty: 'EC',
kid: 'kid-2-simple',
},
],
crls: [
{
kid: 'kid-2-simple',
method: 'rid',
ctr: 1,
rids: ['revoked-1'],
},
],
kty: 'EC',
kid: 'kid-1-simple',
},
{
issuer: {
iss: 'https://example.com/issuer2',
name: 'Example Issuer 2',
},
keys: [
{
kty: 'EC',
kid: 'kid-A-simple',
},
],
kty: 'EC',
kid: 'kid-2-simple',
},
],
crls: [
{
issuer: {
iss: 'https://example.com/issuer3',
name: 'Example Issuer 3',
},
keys: [
{
kty: 'EC',
kid: 'kid-C-simple',
},
],
kid: 'kid-2-simple',
method: 'rid',
ctr: 1,
rids: ['revoked-1'],
},
],
},
{
issuer: {
iss: 'https://example.com/issuer2',
name: 'Example Issuer 2',
},
keys: [
{
issuer: {
iss: 'https://example.com/issuer4',
name: 'Example Issuer 4',
website: 'https://example.com/issuer4',
},
keys: [
{
kty: 'EC',
kid: 'kid-D-simple',
},
],
crls: [
{
kid: 'kid-D-simple',
method: 'rid',
ctr: 1,
rids: ['revoked-2'],
},
],
kty: 'EC',
kid: 'kid-A-simple',
},
],
}
const directory = Directory.fromJSON(directoryJson as DirectoryJSON)
const issuers = directory.getIssuerInfo()
expect(issuers).toHaveLength(4)
},
{
issuer: {
iss: 'https://example.com/issuer3',
name: 'Example Issuer 3',
},
keys: [
{
kty: 'EC',
kid: 'kid-C-simple',
},
],
},
{
issuer: {
iss: 'https://example.com/issuer4',
name: 'Example Issuer 4',
website: 'https://example.com/issuer4',
},
keys: [
{
kty: 'EC',
kid: 'kid-D-simple',
},
],
crls: [
{
kid: 'kid-D-simple',
method: 'rid',
ctr: 1,
rids: ['revoked-2'],
},
],
},
],
}

function assertDirectoryFromSampleJson(directory: Directory) {
const issuers = directory.getIssuerInfo()
expect(issuers).toHaveLength(4)

const issuer1 = issuers[0]!
expect(issuer1.iss).toEqual('https://example.com/issuer')
expect(issuer1.keys).toHaveLength(2)
const crls1 = issuer1.crls!
expect(crls1).toHaveLength(1)
expect(crls1[0]!.kid).toEqual('kid-2-simple')

const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')!
expect(issuer2).toBeDefined()
expect(issuer2.keys).toHaveLength(1)

const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')!
expect(issuer3).toBeDefined()
expect(issuer3.keys).toHaveLength(1)

const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')!
expect(issuer4).toBeDefined()
expect(issuer4.keys).toHaveLength(1)
const crls4 = issuer4.crls!
expect(crls4).toHaveLength(1)
}

const issuer1 = issuers[0]!
expect(issuer1.iss).toEqual('https://example.com/issuer')
expect(issuer1.keys).toHaveLength(2)
const crls1 = issuer1.crls!
expect(crls1).toHaveLength(1)
expect(crls1[0]!.kid).toEqual('kid-2-simple')
describe('Directory', () => {
const ISS_URL = 'https://example.com/issuer'

const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')!
expect(issuer2).toBeDefined()
expect(issuer2.keys).toHaveLength(1)
afterEach(() => {
vi.restoreAllMocks()
})

it('should create a directory from the VCI snapshot', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('vci_snapshot.json')) {
return Promise.resolve({
ok: true,
json: async () => SAMPLE_DIRECTORY_JSON,
})
}

return Promise.resolve({ ok: false, status: 404 })
})
;(globalThis as any).fetch = fetchMock

const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')!
expect(issuer3).toBeDefined()
expect(issuer3.keys).toHaveLength(1)
const directory = await Directory.fromVCI()
assertDirectoryFromSampleJson(directory)

const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')!
expect(issuer4).toBeDefined()
expect(issuer4.keys).toHaveLength(1)
const crls4 = issuer4.crls!
expect(crls4).toHaveLength(1)
;(globalThis as any).fetch = originalFetch
})

it('should throw when VCI snapshot fetch fails', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes('vci_snapshot.json')) {
return Promise.resolve({ ok: false, status: 500 })
}
return Promise.resolve({ ok: false, status: 404 })
})
;(globalThis as any).fetch = fetchMock

await expect(Directory.fromVCI()).rejects.toThrow(
'Failed to fetch VCI Directory snapshot with status 500'
)

;(globalThis as any).fetch = originalFetch
})

it('should create a directory from JSON', () => {
const directory = Directory.fromJSON(SAMPLE_DIRECTORY_JSON as DirectoryJSON)
assertDirectoryFromSampleJson(directory)
})

it('should handle missing or invalid values when building directory using fromJSON', () => {
Expand Down