ica-client-sdk-ts
v2.0.13
Published
ICA Client SDK for TypeScript, supporting React and React Native, for interacting with dataspace-ica-ts services via DIDComm.
Downloads
399
Maintainers
Readme
ICA Client SDK for TypeScript
This SDK allows frontend developers using React or React Native to interact with dataspace-ica-ts services for PDF verification, VC/VP management, DIDComm messaging, and organization did:web document creation.
Index
- Installation
- Configuration
- Usage
- Current Rules
- V2 Binding
- Organization Offboarding
- Verify Response Shape
- Features
- Testing
- Shared Utilities
- Backend Auth (Node/Backend)
Installation
npm install ica-client-sdk-tsShared Workspace
Recommended local layout for the ICA repos and shared PDF fixtures:
~/GITS/gdc-workspace/
dataspace-ica-ts/
ica-client-sdk-ts/
gdc-common-utils-ts/
examples/
<example-pdf-1>.pdf
<example-pdf-2>.pdfWhy this is recommended:
- several docs, examples, and local tests across repos assume a shared
gdc-workspace - real PDF fixtures are expected under
~/GITS/gdc-workspace/examples/ - keeping the repos side by side makes cross-repo changes and verification simpler
If you use a different layout, update your local paths accordingly when running examples or fixture-based tests.
Configuration
How baseUrl is resolved
The SDK resolves ICA URL in this order:
new IcaClient({ baseUrl: ... })process.env.ICA_BASE_URLhttp://localhost:3310
For browser apps, the recommended and most reliable approach is to always pass baseUrl explicitly in the IcaClient constructor.
React (Vite) environment
Use a single environment variable in your React app:
VITE_ICA_BASE_URL=http://localhost:3310Initialize the SDK with that value:
const client = new IcaClient({
sector: Sector.HealthCare,
didWeb: 'did:web:ica',
baseUrl: import.meta.env.VITE_ICA_BASE_URL,
organizationVcs: [],
crypto: globalThis.crypto
});
// Important:
// - `sector` selects the ICA route namespace such as `health-care`
// - it does not define the organization `serviceType` authorizations emitted in
// the resulting OrganizationCredential
// - those authorizations are emitted by ICA in `credentialSubject.makesOffer`
// and are validated later by GW during activationThere is no setServiceType(...) helper in IcaClient for direct ICA _verify.
The SDK does not invent or issue organization service authorizations locally.
ICA emits them in the resulting Organization VC under:
credentialSubject.makesOffer.categorycredentialSubject.makesOffer.serviceType
In other words:
sectorselects the ICA route namespacemakesOffer.serviceTypeis the authorization returned by ICA
SDK local default (Node/test/reference)
The repository includes:
ICA_BASE_URL=http://localhost:3310This variable is used only if you do not provide baseUrl explicitly.
If your runtime does not expose globalThis.crypto, inject a compatible
implementation through IcaClientConfig.crypto. The SDK uses it only for
secure UUID generation.
Usage
import {
extractServiceControllerBindingProjections,
IcaClient,
Sector,
} from 'ica-client-sdk-ts';
const client = new IcaClient({
sector: Sector.HealthCare,
didWeb: 'did:web:ica',
baseUrl: import.meta.env.VITE_ICA_BASE_URL,
organizationVcs: [], // If registered
crypto: globalThis.crypto
});
client.setControllerMessageSigningPublicKey('ES384', 'controller-msg-es384-001', {
kty: 'EC',
crv: 'P-384',
x: '<msg-x>',
y: '<msg-y>'
});
client.setControllerBindingPublicKey('ES384', 'controller-binding-es384-001', {
kty: 'EC',
crv: 'P-384',
x: '<controller-binding-x>',
y: '<controller-binding-y>'
});
client.setOrgCredentialSigningPublicKey('ES384', 'org-cred-es384-001', {
kty: 'EC',
crv: 'P-384',
x: '<cred-x>',
y: '<cred-y>'
});
// Set VP token (signed by frontend)
client.setVpToken(signedVpToken);
// Verify terms PDF
const { thid, location } = await client.verifyTerms(pdfBytesOrLink, {
mediaType: 'application/pdf'
});
// If the signed PDF/certificate does not expose the representative email and
// the BFF needs ICA to include representative credentialSubject.sameAs, the
// BFF must send it explicitly during verifyTerms().
//
// Production/strict recommendation:
// - put the value in the signed annex as `person.email`
// - let ICA derive the canonical `credentialSubject.sameAs = urn:multibase:z...`
// from that plain email
//
// Demo/local fallback:
// - send `legalRepresentativePayload.email` or `.sameAs`
// - if you send `.sameAs` for an email-based identity, use the canonical
// `urn:multibase:z...` value, not `mailto:...`
await client.verifyTerms(pdfBytesOrLink, {
mediaType: 'application/pdf',
legalRepresentativePayload: {
email: '[email protected]'
},
controllerPayload: {
// Build this once with gdc-common-utils-ts buildOrganizationDidWeb() +
// buildProfessionalDidWeb(); ICA and GW then retain the same public actor.
did: controllerDid,
sameAs: controllerSameAs,
publicKeyJwk: controllerBindingPublicKey
},
meta: {
jws: {
protected: {
alg: 'ES384',
kid: 'device-communication-es384-001',
jwk: deviceCommunicationPublicJwk
}
}
}
});
`controllerPayload.did` is optional for older callers. When supplied, the SDK
forwards it unchanged beside the controller JWK; it does not invent a second
DID or derive one from transport metadata.
`controllerPayload.did` and `.sameAs` identify the owner of
`controllerPayload.publicKeyJwk`. In a legacy legal-representative registration,
use the representative identity here even when the signed PDF designates a
different future technical controller. Do not attach the representative JWK to
that future actor. The technical controller later calls the sector flow with
its own DID, alias and JWK before completing DCR.
// The SDK sends FAPI-style request envelopes with:
// - jti: request identifier
// - thid: thread identifier
// For new ICA requests, thid is generated and equals jti.
// The attachment.id is generated as a UUID by default.
// Poll for result.
// The returned object follows the same shape as dataspace-ica-ts OpenAPI
// for POST /terms/pdf/contract/_verify-response.
const verifyResponse = await client.pollVerifyTermsResponse(thid);
// Important:
// - ICA does not infer this email from the BFF login/registration by itself
// - if the BFF does not send it and the signed PDF/certificate does not carry
// it either, ICA has no representative email to map into the VC
// - the payload fallback above is a demo/local convenience and should not
// replace signed `person.email` evidence in production
// v2 bootstrap helpers:
// - organization public/private JWK live outside resource on the organization entry
// - a separately bound controller JWK lives on the ServiceController entry
// - a legacy representative-owned JWK lives on the legal representative entry
const organizationKeyMaterial = client.getOrganizationKeyMaterialFromVerifyResponse(verifyResponse);
const controllerBindingPublicKey = client.getControllerBindingPublicKeyFromVerifyResponse(verifyResponse);
// Get VC JWT attachments from the DIDComm response
const {
organizationVC,
legalRepresentativeVC,
organizationControllerVCs, // deprecated transport label, still readable
allVcs,
} = client.getVcsFromResponse(verifyResponse);
// Get the credential objects from verify-response body.data[].resource
const organizationCredential = client.getOrganizationCredentialFromVerifyResponse(verifyResponse);
const legalRepresentativeCredential = client.getLegalRepresentativeCredentialFromVerifyResponse(verifyResponse);
const serviceControllerCredentials =
client.getServiceControllerCredentialsFromVerifyResponse(verifyResponse);
// Binding projections keep authority and professional occupation independent:
// { roleCodes: ['RESPRSN'], occupationCodes: ['ISCO-08|1330'], ... }
const controllerBindings = extractServiceControllerBindingProjections(verifyResponse);
// Get structured credentialSubject info using schema.org-style shapes
const organizationInfo = client.getOrganizationInfoFromVerifyResponse(verifyResponse);
const legalRepresentativeInfo = client.getLegalRepresentativeInfoFromVerifyResponse(verifyResponse);
const serviceControllerInfo =
client.getServiceControllerInfoFromVerifyResponse(verifyResponse);
console.log(organizationInfo?.legalName);
console.log(organizationInfo?.taxID);
console.log(organizationInfo?.address?.addressCountry);
console.log(organizationInfo?.makesOffer?.category);
console.log(organizationInfo?.makesOffer?.serviceType);
console.log(legalRepresentativeInfo?.givenName);
console.log(legalRepresentativeInfo?.familyName);
console.log(legalRepresentativeInfo?.identifier); // National ID
console.log(organizationControllerInfo[0]?.owner?.sameAs);
console.log(organizationControllerInfo[0]?.owner?.hasCredential);
// - meta.jws.protected.jwk is the DIDComm communication key
// - body.data[].resource.controller.publicKeyJwk is the controller business binding key
// - the organization credential-signing key is sent as an extra JWK attachment
// during verifyTerms() if setOrgCredentialSigningPublicKey() is configured
// - if the organization key was ICA-generated in _verify (keySource=generated),
// _create must send organization.publicKeyJwk again as explicit confirmation
const { thid: createThid } = await client.createOrgDidDocument({
organization: {
identifier: 'did:web:globaldatacare.es:animal-care:organization:taxid:VATES-B00000000',
publicKeyJwk: organizationKeyMaterial.publicKeyJwk,
jwks: {
keys: [
{
kid: 'org-didcomm-enc-001',
kty: 'EC',
crv: 'P-384',
x: '<org-enc-x>',
y: '<org-enc-y>',
use: 'enc',
purposes: ['didcomm-enc']
}
]
}
},
controller: {
sameAs: 'urn:multibase:zControllerHash',
publicKeyJwk: controllerBindingPublicKey,
jwks: {
keys: [
{
kid: 'controller-didcomm-sign-001',
kty: 'EC',
crv: 'P-384',
x: '<controller-sign-x>',
y: '<controller-sign-y>',
use: 'sig',
purposes: ['didcomm-sign']
}
]
}
}
});
const createResponse = await client.pollCreateOrgDidDocumentResponse(createThid);
const { thid: derivedThid } = await client.createOrgDidDocumentFromVcs({
organizationVC: organizationCredential,
legalRepresentativeVC: legalRepresentativeCredential,
organizationPublicKeyJwk: organizationKeyMaterial.publicKeyJwk,
controllerPublicKeyJwk: controllerBindingPublicKey
});
const derivedCreateResponse = await client.pollCreateOrgDidDocumentResponse(derivedThid);
// Prepare DIDComm message
const message = client.prepareDidCommRequest('type', body, attachments);
client.includeVpTokenInMessage(message);
client.includeFileInMessage(message, fileBytes, 'application/pdf', 'file-id');BFF demo fallback for representative email
If the BFF wants ICA to include representative credentialSubject.sameAs in
the returned VC, and the signed PDF/certificate does not already carry that
contact value, the BFF may send it explicitly during verifyTerms(...).
Important scope:
- this fallback is only for
demo/localICA mode - production should prefer signed
person.emailin the PDF annex - ICA does not infer this email from the BFF login/session automatically
- representative proof is two-dimensional:
credentialSubject.sameAsfor public identity continuitycredentialSubject.hasCredential.materialfor controller signing-key continuity
- production-grade VCs should ideally carry both dimensions
Canonical input rules:
- if the BFF has the raw email, send it as
legalRepresentativePayload.email - if the BFF already has the canonical hashed alias, send it as
legalRepresentativePayload.sameAs - for email-based identity, canonical
sameAsisurn:multibase:z..., notmailto:...
Step by step:
- Prefer putting
person.emailin the signed PDF annex so ICA can derive the representativesameAsfrom signed evidence. - If demo/local onboarding does not have that email in the PDF or certificate, send
legalRepresentativePayload.emailduringverifyTerms(...). - If the BFF already computed the canonical hash, send
legalRepresentativePayload.sameAsinstead. - Expect production-grade ICA responses to carry both
credentialSubject.sameAsandcredentialSubject.hasCredential.material.
Example:
await client.verifyTerms(pdfBytesOrLink, {
mediaType: 'application/pdf',
legalRepresentativePayload: {
email: '[email protected]'
}
});Equivalent explicit sameAs example:
await client.verifyTerms(pdfBytesOrLink, {
mediaType: 'application/pdf',
legalRepresentativePayload: {
sameAs: 'urn:multibase:zControllerHash'
}
});Reading organization service authorization from _verify
For legal-organization onboarding, read the authorization from the ICA-issued Organization VC. The relevant SDK getters are:
getOrganizationCredentialFromVerifyResponse(response)getOrganizationInfoFromVerifyResponse(response)
Example:
const verifyResponse = await client.pollVerifyTermsResponse(thid);
const organizationCredential =
client.getOrganizationCredentialFromVerifyResponse(verifyResponse);
const organizationInfo =
client.getOrganizationInfoFromVerifyResponse(verifyResponse);
console.log(organizationCredential?.credentialSubject?.makesOffer?.category);
console.log(organizationCredential?.credentialSubject?.makesOffer?.serviceType);
console.log(organizationInfo?.makesOffer?.category);
console.log(organizationInfo?.makesOffer?.serviceType);Typed SDK fields:
IcaOrganizationCredentialSubject.makesOfferIcaOrganizationOffer.categoryIcaOrganizationOffer.serviceType
If a host flow needs to declare requested capabilities before ICA answers,
that belongs to the higher-level GW CORE / host _transaction contract, not
to the direct ICA _verify SDK constructor.
Current Rules
_verifyis still the onboarding verification step; it is not the place to add new organization keys after onboarding._createis the current step that publishes the organization DID document.- Treat the DIDComm communication key separately from the controller business binding key and from VC-signing keys.
- For organization VC signing, prefer
ES384as the primary VC-signing key inorganization.publicKeyJwk. - If Pontus-X compatibility is needed, publish
ES256Kas an additional key inorganization.jwks.keys[], not as the primary key. - The same key-model should be reused later for employee DID documents and employee keys.
Recommended SDK usage:
setControllerMessageSigningPublicKey(alg, kid, jwk)for DIDComm communication protectionsetControllerBindingPublicKey(alg, kid, jwk)for the controller business binding projected intocredentialSubject.hasCredential.materialsetOrgCredentialSigningPublicKey(alg, kid, jwk)to send the organization public JWK attachment in_verify- in
_create, explicitcontroller.publicKeyJwkandorganization.publicKeyJwkare still valid for v1 compatibility when no stored binding/bootstrap key exists yet - if
_verifyalready stored the controller binding, an explicitcontroller.publicKeyJwkin_createmust match it exactly - if
_verifyalready stored the organization key, an explicitorganization.publicKeyJwkin_createmust match it exactly - if
_verifyreturnedkeySource: "generated", pass backorganizationKeyMaterial.publicKeyJwkto_createas confirmation before publishing the DID document
V2 Binding
The v2 onboarding flow separates communication and controller binding during _verify:
meta.jws.protected.jwk: DIDComm communication key of the device profile, confidential app, or BFFbody.data[].resource.controller.publicKeyJwk: controller business binding key projected intocredentialSubject.hasCredential.material
The communication key is for transport protection. It is not automatically the same key that will sign controller operations, VCs for SMART-on-FHIR, EUDI Wallet, or Pontus-X.
The organization credential-signing key is separate:
- send it as an extra
application/jwk+jsonattachment in_verify, or - let ICA autogenerate
ES384and read the returnedpublicKeyJwk/privateKeyJwkfrom_verify-response
Important security rule:
- the initial onboarding transaction can bind the first organization key,
- reusing the same contract to bind a different key must be rejected,
- post-onboarding key addition or rotation must use a dedicated key-management endpoint, not
_verify. - if
_verifyalready bound a controller key,_createcannot replace it. - if
_verifyalready stored an organization key,_createcannot replace it. - if ICA generated the organization keypair in
_verify, the caller must keep it and confirm the samepublicKeyJwkin_create.
Today, the SDK can already transport both:
meta.jws.protected.jwkfor the communication keybody.data[].resource.controller.publicKeyJwkfor the controller binding key- the organization public JWK attachment for the organization credential key
Organization Offboarding
Organization offboarding should not use the current network credential _revoke endpoint.
For organization lifecycle, the intended business endpoint is:
POST /ica/cds-{jurisdiction}/v1/{sector}/terms/pdf/{resourceType}/_remove
POST /ica/cds-{jurisdiction}/v1/{sector}/terms/pdf/{resourceType}/_remove-responseIntended semantics:
- the organization no longer accepts those terms
- the organization is removed from the active catalog
- the organization DID document is removed from active publication
- organization keys are revoked/deactivated
- a later return requires a full new onboarding cycle (
_verify->_create)
Authorization model:
- preferred input:
controller.publicKeyJwkin request body - didactic legacy mode: controller key may still travel as
meta.jws.protected.jwk - hardened mode: request should be real
didcomm-signed, optionally wrapped indidcomm-encrypted
Important:
- this is different from
network/credentials/{credentialType}/_revoke network ... _revokeis for credential lifecycleterms ... _removeis for organization participation / accepted-terms lifecycle
The SDK now exposes:
removeOrganizationTerms()pollRemoveOrganizationTermsResponse()
Frontend method pair for offboarding:
- call
removeOrganizationTerms() - then poll with
pollRemoveOrganizationTermsResponse()
For local manual testing today, the realistic flow is:
_verify_create_remove
The full organization offboarding cycle is:
Example:
y: '' });
const removeResponse = await client.pollRemoveOrganizationTermsResponse(removeJob.thid);
If the organization is created again after `_remove`, the same pair is used again to confirm a second full cycle:
```ts
const secondRemoveJob = await client.removeOrganizationTerms({
organization: {
identifier: 'did:web:globaldatacare.es:animal-care:organization:taxid:VATES-B42215152',
taxID: 'VATES-B42215152'
},
reason: 'organization-requested-removal'
});
const secondRemoveResponse = await client.pollRemoveOrganizationTermsResponse(secondRemoveJob.thid);Verify Response Shape
pollVerifyTermsResponse() returns the DIDComm envelope documented by dataspace-ica-ts for _verify-response:
type VerifyResponse = {
thid?: string;
attachments?: Array<{
id?: string;
format?: string;
resourceType?: 'Bundle';
type?: 'batch-response';
total?: number;
status?: string;
};
resource?: {
type?: string[];
credentialSubject?: Record<string, unknown>;
};
}>;
};
};getVcsFromResponse(response)getLegalRepresentativeCredentialFromVerifyResponse(response)getServiceControllerCredentialsFromVerifyResponse(response)getServiceControllerInfoFromVerifyResponse(response)getOrganizationInfoFromVerifyResponse(response)Using thedataspace-ica-tsOpenAPI example, the main fields come from:response.body.data[1].resource.credentialSubject.familyNameresponse.body.data[1].resource.credentialSubject.identifier
Features
Testing
Run tests against local ICA instance:
npm testEnsure ICA is running on port 3310.
Shared Utilities
The SDK re-exports shared DIDComm utilities from gdc-common-utils-ts:
import { prepareDidCommRequest, includeVpTokenInMessage, includeFileInMessage, getThidFromMessage, getDataResults } from 'ica-client-sdk-ts';These utilities already live in gdc-common-utils-ts and are also re-exported by other SDKs such as dataconv-client-sdk-ts.
If you want to depend on the common base module directly, import them from gdc-common-utils-ts/utils/didcomm.
Backend Auth (Node/Backend)
This SDK includes ICA backend-auth helpers for the custom async profile identity-exchange.v1:
controllerExchange(...)+pollControllerExchangeResponse(...)createApiKey(...),disableApiKey(...),removeApiKey(...),searchApiKeys(...)createApiKeyRules(...)(atomic policy helper: one rule entry per authorization rule)pollApiKeyActionResponse(thid, bearerToken, action)identityDcr(...),pollIdentityDcrResponse(...)identityDcrWithBinding(...)(parameter-first DCR helper)identityCode(...),pollIdentityCodeResponse(...)identityToken(...),pollIdentityTokenResponse(...)identityExchange(...),pollIdentityExchangeResponse(...)runBackendAuthFlow(...)for DCR + PKCE + exchange orchestration
Notes:
identity-exchange.v1is ICA custom flow:/_dcr -> /_code -> /_token -> /_exchange(all async submit/poll).- SMART Backend Services (
client_credentials + private_key_jwt) is a separate OAuth profile. _dcrin this section is backend technical identity binding (client_id+ technical JWK). It is not the human controller VP/Clearing House flow.- For
identity-exchange.v1, runtimeclient_idis still required by_code/_token/_exchange. - API key provisioning policy is atomic: each
data[].resourceis one authorization rule.instrument(ODRL policy object, recommended),- optional expiry.
How meta.jws.protected should be handled depends on transport profile:
didcomm-plain:didcomm-signed:
- JWS protected header is produced by the signing layer,
- client business API should not manually inject
meta.jws.protected.
didcomm-encrypted(with nested JWS):
- outer JWE/JWS headers are produced by cryptographic envelope handling,
- business methods should pass key/material parameters and let envelope tooling generate headers.
Current implementation status:
- Backend-auth methods in this SDK currently target
application/didcomm-plain+json. identityDcrWithBinding(...)is implemented for parameter-first DCR input.- Signed/encrypted envelope generation is still handled outside these backend-auth methods.
Parameter-First Inputs (audit contract)
For backend identity flows, prefer explicit variables over raw JSON blobs:
clientIdbearerTokencodeVerifiercodeChallengeMethod(S256default)controllerSigAlg(exampleES384)controllerSigKidcontrollerSigPublicJwktransportProtection(plain|signed|encrypted)
These variables define intent. Envelope/meta serialization should be generated by SDK crypto/envelope helpers according to transportProtection.
Minimal identity flow example:
const dcrSubmit = await client.identityDcr('<api-key-value>', {}, {
bearerToken: '<controller-access-token>',
meta: {
jws: {
protected: {
alg: 'ES384',
jwk: { kty: 'EC', crv: 'P-384', x: '<x>', y: '<y>' }
}
}
}
});
await client.pollIdentityDcrResponse(dcrSubmit.thid, '<controller-access-token>');
const codeSubmit = await client.identityCode({
client_id: '<api-key-value>',
code_challenge: '<pkce-s256-challenge>',
code_challenge_method: 'S256'
}, { bearerToken: '<controller-access-token>' });
const codeResponse = await client.pollIdentityCodeResponse(codeSubmit.thid, '<controller-access-token>');Parameter-first DCR helper (implemented):
await client.identityDcrWithBinding({
clientId: '<api-key-value>',
bearerToken: '<controller-access-token>',
controllerSigAlg: 'ES384',
controllerSigKid: 'controller-es384-001',
controllerSigPublicJwk: { kty: 'EC', crv: 'P-384', x: '<x>', y: '<y>' },
transportProtection: 'plain'
});Backend Onboarding Variants
For security audits, keep these two variants explicit:
- Controller-led provisioning:
- A human controller authenticates in ICA and creates backend API keys (
/_create). - The controller executes
_dcrusing backend public key material provided out-of-band. - Backend starts in pre-bound mode and should skip
_dcrif binding is alreadybound.
- Backend-led binding (same API contract):
- The controller still authorizes API key issuance, but backend performs
_dcritself through the SDK. - No human VP token is required inside
_dcr;_dcris technical key binding for backend identity.
After binding is bound, runtime token issuance uses _code -> _token -> _exchange for identity-exchange.v1.
runBackendAuthFlow(...) now supports DCR execution policy:
dcrMode: "force"(default): always run_dcr.dcrMode: "skip": skip_dcrexplicitly.dcrMode: "auto": skip_dcrwhen known/observed binding isbound.
Roadmap and Briefing
BRIEFING_DATASPACE_EN.mdTODO_ROADMAP.md
