vet-data-utils-ts 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/dist/immunization.d.ts +50 -0
- package/dist/immunization.js +48 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/international-health-card.d.ts +34 -0
- package/dist/international-health-card.js +68 -0
- package/dist/shc.d.ts +73 -0
- package/dist/shc.js +268 -0
- package/package.json +16 -2
package/README.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# vet-data-utils-ts
|
|
2
2
|
|
|
3
|
+
## Veterinary immunization credentials
|
|
4
|
+
|
|
5
|
+
`buildVeterinaryImmunization(...)` creates a FHIR R4 `Immunization` with the
|
|
6
|
+
animal in `patient` and the veterinarian's real `PractitionerRole` in
|
|
7
|
+
`performer.actor`. The caller must supply a governed vaccine coding selected
|
|
8
|
+
for the animal species, jurisdiction and current terminology release; this
|
|
9
|
+
package does not guess a rabies or medicinal-product code.
|
|
10
|
+
|
|
11
|
+
`prepareSmartHealthCard(...)` minimizes a FHIR Bundle, serializes it
|
|
12
|
+
deterministically and returns the exact raw-DEFLATE ES256 signing input. The
|
|
13
|
+
tenant issuer signs that input with its dedicated P-256 credential key and
|
|
14
|
+
publishes the matching public JWK at `iss + /.well-known/jwks.json`.
|
|
15
|
+
`encodeSmartHealthCardQr(...)` then produces standard `shc:/` numeric QR data.
|
|
16
|
+
|
|
17
|
+
`encodePostQuantumCompanionProofUri(...)` carries an RFC 7797 detached ML-DSA
|
|
18
|
+
JWS as `pqc:/...`. This is a VetChain transition transport, not part of the
|
|
19
|
+
SMART Health Cards standard. `encodePostQuantumCompanionProofQr(...)` applies
|
|
20
|
+
the same two-decimal-digits-per-JWS-character encoding used by SHC, defaults to
|
|
21
|
+
two numbered large QR labels, and never repeats the external payload. Each
|
|
22
|
+
attester signs the exact uncompressed SHC payload bytes; verification therefore
|
|
23
|
+
requires both QR sets.
|
|
24
|
+
|
|
25
|
+
`buildSmartHealthCardPayloadReferences(...)` returns two deliberately distinct
|
|
26
|
+
SHA3-384 identifiers over those exact canonical bytes: a
|
|
27
|
+
`urn:multibase:z...` multihash for exact ledger lookup and a base58btc CIDv1
|
|
28
|
+
with the `raw` codec for content-addressed retrieval. The IHC print projection
|
|
29
|
+
shows FHIR `Immunization.occurrenceDateTime`, `vaccineCode` and
|
|
30
|
+
`protocolApplied.targetDisease`. Its issuer-supplied validity interval is not
|
|
31
|
+
FHIR `Immunization.expirationDate`, which means vaccine-batch expiry.
|
|
32
|
+
|
|
3
33
|
Development and releases follow the mandatory
|
|
4
34
|
[`local-first TDD and release contract`](docs/LOCAL_FIRST_RELEASE_CONTRACT.md).
|
|
5
35
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -11,3 +11,9 @@ The generated ISO jurisdiction catalogue is derived from Debian `iso-codes`
|
|
|
11
11
|
|
|
12
12
|
The generated catalogue may be replaced or updated independently by running
|
|
13
13
|
`npm run update:iso-jurisdictions` against the version pinned in that script.
|
|
14
|
+
|
|
15
|
+
SMART Health Card payloads use `pako` for browser-safe RFC 1951 raw DEFLATE:
|
|
16
|
+
|
|
17
|
+
- Source: https://github.com/nodeca/pako
|
|
18
|
+
- Copyright: Vitaly Puzrin and Andrey Tupitsin
|
|
19
|
+
- License: MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type FhirCoding = Readonly<{
|
|
2
|
+
system: string;
|
|
3
|
+
code: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
display?: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type FhirCodeableConcept = Readonly<{
|
|
8
|
+
coding: readonly FhirCoding[];
|
|
9
|
+
text?: string;
|
|
10
|
+
}>;
|
|
11
|
+
export type VeterinaryImmunizationInput = Readonly<{
|
|
12
|
+
identifier?: Readonly<{
|
|
13
|
+
system?: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}>;
|
|
16
|
+
animalReference: string;
|
|
17
|
+
occurrenceDateTime: string;
|
|
18
|
+
vaccineCode: FhirCodeableConcept;
|
|
19
|
+
performerPractitionerRoleReference: string;
|
|
20
|
+
primarySource?: boolean;
|
|
21
|
+
lotNumber?: string;
|
|
22
|
+
expirationDate?: string;
|
|
23
|
+
manufacturerReference?: string;
|
|
24
|
+
site?: FhirCodeableConcept;
|
|
25
|
+
route?: FhirCodeableConcept;
|
|
26
|
+
doseQuantity?: Readonly<{
|
|
27
|
+
value: number;
|
|
28
|
+
unit?: string;
|
|
29
|
+
system?: string;
|
|
30
|
+
code?: string;
|
|
31
|
+
}>;
|
|
32
|
+
protocolApplied?: readonly Readonly<{
|
|
33
|
+
series?: string;
|
|
34
|
+
doseNumberPositiveInt?: number;
|
|
35
|
+
seriesDosesPositiveInt?: number;
|
|
36
|
+
targetDisease?: readonly FhirCodeableConcept[];
|
|
37
|
+
}>[];
|
|
38
|
+
}>;
|
|
39
|
+
export type VeterinaryImmunizationR4 = Readonly<Record<string, unknown> & {
|
|
40
|
+
resourceType: 'Immunization';
|
|
41
|
+
status: 'completed';
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Builds the FHIR R4 Immunization recorded by a veterinary professional.
|
|
45
|
+
*
|
|
46
|
+
* Vaccine coding is deliberately supplied by the caller: the applicable code
|
|
47
|
+
* system and allowed vaccines depend on species, jurisdiction and the active
|
|
48
|
+
* terminology release. This helper never guesses a rabies or product code.
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildVeterinaryImmunization(input: Partial<VeterinaryImmunizationInput>): VeterinaryImmunizationR4;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function required(value, errorCode) {
|
|
2
|
+
if (typeof value !== 'string' || !value.trim())
|
|
3
|
+
throw new TypeError(errorCode);
|
|
4
|
+
return value.trim();
|
|
5
|
+
}
|
|
6
|
+
function validateCodeableConcept(value) {
|
|
7
|
+
if (!value || typeof value !== 'object')
|
|
8
|
+
throw new TypeError('veterinary_immunization_vaccine_coding_required');
|
|
9
|
+
const coding = value.coding;
|
|
10
|
+
if (!Array.isArray(coding) || coding.length === 0 || coding.some(item => {
|
|
11
|
+
if (!item || typeof item !== 'object')
|
|
12
|
+
return true;
|
|
13
|
+
const candidate = item;
|
|
14
|
+
return typeof candidate.system !== 'string' || !candidate.system.trim()
|
|
15
|
+
|| typeof candidate.code !== 'string' || !candidate.code.trim();
|
|
16
|
+
}))
|
|
17
|
+
throw new TypeError('veterinary_immunization_vaccine_coding_required');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the FHIR R4 Immunization recorded by a veterinary professional.
|
|
21
|
+
*
|
|
22
|
+
* Vaccine coding is deliberately supplied by the caller: the applicable code
|
|
23
|
+
* system and allowed vaccines depend on species, jurisdiction and the active
|
|
24
|
+
* terminology release. This helper never guesses a rabies or product code.
|
|
25
|
+
*/
|
|
26
|
+
export function buildVeterinaryImmunization(input) {
|
|
27
|
+
const animalReference = required(input.animalReference, 'veterinary_immunization_animal_reference_required');
|
|
28
|
+
const performerReference = required(input.performerPractitionerRoleReference, 'veterinary_immunization_performer_practitioner_role_required');
|
|
29
|
+
const occurrenceDateTime = required(input.occurrenceDateTime, 'veterinary_immunization_occurrence_required');
|
|
30
|
+
validateCodeableConcept(input.vaccineCode);
|
|
31
|
+
return {
|
|
32
|
+
resourceType: 'Immunization',
|
|
33
|
+
...(input.identifier ? { identifier: [{ ...input.identifier }] } : {}),
|
|
34
|
+
status: 'completed',
|
|
35
|
+
vaccineCode: input.vaccineCode,
|
|
36
|
+
patient: { reference: animalReference },
|
|
37
|
+
occurrenceDateTime,
|
|
38
|
+
primarySource: input.primarySource ?? true,
|
|
39
|
+
performer: [{ actor: { reference: performerReference } }],
|
|
40
|
+
...(input.lotNumber ? { lotNumber: input.lotNumber } : {}),
|
|
41
|
+
...(input.expirationDate ? { expirationDate: input.expirationDate } : {}),
|
|
42
|
+
...(input.manufacturerReference ? { manufacturer: { reference: input.manufacturerReference } } : {}),
|
|
43
|
+
...(input.site ? { site: input.site } : {}),
|
|
44
|
+
...(input.route ? { route: input.route } : {}),
|
|
45
|
+
...(input.doseQuantity ? { doseQuantity: input.doseQuantity } : {}),
|
|
46
|
+
...(input.protocolApplied ? { protocolApplied: input.protocolApplied } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,9 +6,12 @@ export * from './digital-twin.js';
|
|
|
6
6
|
export * from './emergency.js';
|
|
7
7
|
export * from './financial.js';
|
|
8
8
|
export * from './group.js';
|
|
9
|
+
export * from './immunization.js';
|
|
10
|
+
export * from './international-health-card.js';
|
|
9
11
|
export * from './iso-jurisdictions.js';
|
|
10
12
|
export * from './organization-application.js';
|
|
11
13
|
export * from './payment.js';
|
|
12
14
|
export * from './research-study.js';
|
|
13
15
|
export * from './sectors.js';
|
|
16
|
+
export * from './shc.js';
|
|
14
17
|
export * from './veterinary-sections.js';
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,12 @@ export * from './digital-twin.js';
|
|
|
6
6
|
export * from './emergency.js';
|
|
7
7
|
export * from './financial.js';
|
|
8
8
|
export * from './group.js';
|
|
9
|
+
export * from './immunization.js';
|
|
10
|
+
export * from './international-health-card.js';
|
|
9
11
|
export * from './iso-jurisdictions.js';
|
|
10
12
|
export * from './organization-application.js';
|
|
11
13
|
export * from './payment.js';
|
|
12
14
|
export * from './research-study.js';
|
|
13
15
|
export * from './sectors.js';
|
|
16
|
+
export * from './shc.js';
|
|
14
17
|
export * from './veterinary-sections.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type SmartHealthCardPayloadReferences } from './shc.js';
|
|
2
|
+
export type InternationalHealthCardCodedValue = Readonly<{
|
|
3
|
+
system: string;
|
|
4
|
+
code: string;
|
|
5
|
+
display?: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type InternationalHealthCardVaccination = Readonly<{
|
|
8
|
+
resourceType: 'Immunization';
|
|
9
|
+
administeredAt: string;
|
|
10
|
+
validFrom: string;
|
|
11
|
+
validUntil: string;
|
|
12
|
+
product: InternationalHealthCardCodedValue;
|
|
13
|
+
targetDiseases: readonly InternationalHealthCardCodedValue[];
|
|
14
|
+
authorOrganizationUrn: string;
|
|
15
|
+
/** FHIR Immunization.expirationDate: expiry of the vaccine batch, not credential validity. */
|
|
16
|
+
vaccineBatchExpiresOn?: string;
|
|
17
|
+
}>;
|
|
18
|
+
export type InternationalHealthCardPrintData = Readonly<{
|
|
19
|
+
vaccination: InternationalHealthCardVaccination;
|
|
20
|
+
payloadReferences: SmartHealthCardPayloadReferences;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Projects human-readable IHC data from the authoritative FHIR R4 resource.
|
|
24
|
+
* `validFrom` and `validUntil` are credential/travel-policy dates supplied by
|
|
25
|
+
* the issuer; FHIR `Immunization.expirationDate` remains the vaccine batch
|
|
26
|
+
* expiry and is never reused as the validity end.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildInternationalHealthCardPrintData(input: Readonly<{
|
|
29
|
+
authoritativeBundle: Record<string, unknown>;
|
|
30
|
+
validFrom: string;
|
|
31
|
+
validUntil: string;
|
|
32
|
+
authorOrganizationUrn: string;
|
|
33
|
+
payloadBytes: Uint8Array;
|
|
34
|
+
}>): InternationalHealthCardPrintData;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { buildSmartHealthCardPayloadReferences } from './shc.js';
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
function requiredText(value, error) {
|
|
6
|
+
if (typeof value !== 'string' || !value.trim())
|
|
7
|
+
throw new TypeError(error);
|
|
8
|
+
return value.trim();
|
|
9
|
+
}
|
|
10
|
+
function firstCoding(value, error) {
|
|
11
|
+
const concept = record(value);
|
|
12
|
+
const coding = Array.isArray(concept?.coding) ? concept.coding.map(record).find(Boolean) : undefined;
|
|
13
|
+
if (!coding)
|
|
14
|
+
throw new TypeError(error);
|
|
15
|
+
const system = requiredText(coding.system, error);
|
|
16
|
+
const code = requiredText(coding.code, error);
|
|
17
|
+
const display = typeof coding.display === 'string' && coding.display.trim()
|
|
18
|
+
? coding.display.trim()
|
|
19
|
+
: typeof concept?.text === 'string' && concept.text.trim() ? concept.text.trim() : undefined;
|
|
20
|
+
return { system, code, ...(display ? { display } : {}) };
|
|
21
|
+
}
|
|
22
|
+
function targetDiseases(immunization) {
|
|
23
|
+
const protocols = Array.isArray(immunization.protocolApplied) ? immunization.protocolApplied : [];
|
|
24
|
+
return protocols.flatMap(protocolValue => {
|
|
25
|
+
const protocol = record(protocolValue);
|
|
26
|
+
const diseases = Array.isArray(protocol?.targetDisease) ? protocol.targetDisease : [];
|
|
27
|
+
return diseases.map(disease => firstCoding(disease, 'international_health_card_target_disease_coding_required'));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Projects human-readable IHC data from the authoritative FHIR R4 resource.
|
|
32
|
+
* `validFrom` and `validUntil` are credential/travel-policy dates supplied by
|
|
33
|
+
* the issuer; FHIR `Immunization.expirationDate` remains the vaccine batch
|
|
34
|
+
* expiry and is never reused as the validity end.
|
|
35
|
+
*/
|
|
36
|
+
export function buildInternationalHealthCardPrintData(input) {
|
|
37
|
+
const entries = Array.isArray(input.authoritativeBundle?.entry) ? input.authoritativeBundle.entry : [];
|
|
38
|
+
const immunization = entries
|
|
39
|
+
.map(entry => record(record(entry)?.resource))
|
|
40
|
+
.find(resource => resource?.resourceType === 'Immunization' && resource.status === 'completed');
|
|
41
|
+
if (!immunization)
|
|
42
|
+
throw new TypeError('international_health_card_completed_immunization_required');
|
|
43
|
+
const validFrom = requiredText(input.validFrom, 'international_health_card_valid_from_required');
|
|
44
|
+
const validUntil = requiredText(input.validUntil, 'international_health_card_valid_until_required');
|
|
45
|
+
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validUntil))
|
|
46
|
+
|| Date.parse(validFrom) > Date.parse(validUntil)) {
|
|
47
|
+
throw new TypeError('international_health_card_validity_invalid');
|
|
48
|
+
}
|
|
49
|
+
const authorOrganizationUrn = requiredText(input.authorOrganizationUrn, 'international_health_card_author_organization_urn_required');
|
|
50
|
+
if (!/^urn:cds-[a-z]{2}(?:-[a-z0-9]{2,3})?:v[1-9][0-9]*:organization:[a-z][a-z0-9-]*:[^:]+$/i.test(authorOrganizationUrn)) {
|
|
51
|
+
throw new TypeError('international_health_card_author_organization_urn_invalid');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
vaccination: {
|
|
55
|
+
resourceType: 'Immunization',
|
|
56
|
+
administeredAt: requiredText(immunization.occurrenceDateTime, 'international_health_card_occurrence_required'),
|
|
57
|
+
validFrom,
|
|
58
|
+
validUntil,
|
|
59
|
+
product: firstCoding(immunization.vaccineCode, 'international_health_card_vaccine_coding_required'),
|
|
60
|
+
targetDiseases: targetDiseases(immunization),
|
|
61
|
+
authorOrganizationUrn,
|
|
62
|
+
...(typeof immunization.expirationDate === 'string' && immunization.expirationDate.trim()
|
|
63
|
+
? { vaccineBatchExpiresOn: immunization.expirationDate.trim() }
|
|
64
|
+
: {}),
|
|
65
|
+
},
|
|
66
|
+
payloadReferences: buildSmartHealthCardPayloadReferences(input.payloadBytes),
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/shc.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export declare const SmartHealthCardQrPrefix: "shc:/";
|
|
2
|
+
export declare const PostQuantumCompanionProofPrefix: "pqc:/";
|
|
3
|
+
export declare const SmartHealthCardMaxSingleQrLength = 1195;
|
|
4
|
+
export declare const SmartHealthCardMaxQrChunkBodyLength = 1191;
|
|
5
|
+
export declare const PostQuantumCompanionProofDefaultQrCount = 2;
|
|
6
|
+
export type PostQuantumCompanionProofQrOptions = Readonly<{
|
|
7
|
+
/** Exact number of companion QR labels produced per detached proof. Defaults to two. */
|
|
8
|
+
qrCount?: number;
|
|
9
|
+
}>;
|
|
10
|
+
export type SmartHealthCardPayloadReferences = Readonly<{
|
|
11
|
+
/** Exact SHA3-384 multihash encoded as base58btc and wrapped for ledger lookup. */
|
|
12
|
+
multihashUrn: string;
|
|
13
|
+
/** CIDv1(raw, SHA3-384) encoded as base58btc for content-addressed retrieval. */
|
|
14
|
+
cidV1: string;
|
|
15
|
+
}>;
|
|
16
|
+
export type SmartHealthCardPayload = Readonly<{
|
|
17
|
+
iss: string;
|
|
18
|
+
nbf: number;
|
|
19
|
+
vc: Readonly<{
|
|
20
|
+
type: readonly string[];
|
|
21
|
+
credentialSubject: Readonly<{
|
|
22
|
+
fhirVersion: string;
|
|
23
|
+
fhirBundle: Record<string, unknown>;
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
}>;
|
|
27
|
+
export type PreparedSmartHealthCard = Readonly<{
|
|
28
|
+
protectedHeader: Readonly<{
|
|
29
|
+
alg: 'ES256';
|
|
30
|
+
zip: 'DEF';
|
|
31
|
+
kid: string;
|
|
32
|
+
}>;
|
|
33
|
+
payload: SmartHealthCardPayload;
|
|
34
|
+
payloadBytes: Uint8Array;
|
|
35
|
+
encodedHeader: string;
|
|
36
|
+
encodedPayload: string;
|
|
37
|
+
signingInput: string;
|
|
38
|
+
signingBytes: Uint8Array;
|
|
39
|
+
}>;
|
|
40
|
+
/** Applies the payload-size rules required by the SMART Health Cards framework. */
|
|
41
|
+
export declare function minimizeFhirBundleForSmartHealthCard(input: Record<string, unknown>): Record<string, unknown>;
|
|
42
|
+
/** Prepares the exact raw-DEFLATE compact-JWS input to be signed by an external ES256 issuer key. */
|
|
43
|
+
export declare function prepareSmartHealthCard(input: Readonly<{
|
|
44
|
+
issuer: string;
|
|
45
|
+
notBefore: number;
|
|
46
|
+
kid: string;
|
|
47
|
+
fhirVersion: string;
|
|
48
|
+
fhirBundle: Record<string, unknown>;
|
|
49
|
+
additionalTypes?: readonly string[];
|
|
50
|
+
}>): PreparedSmartHealthCard;
|
|
51
|
+
export declare function assembleSmartHealthCardJws(prepared: Pick<PreparedSmartHealthCard, 'encodedHeader' | 'encodedPayload'>, signatureBase64Url: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Builds both project-supported base58btc references over the exact canonical
|
|
54
|
+
* SHC payload bytes. The multihash URN is a ledger lookup key; the CID is a
|
|
55
|
+
* content identifier. They are deliberately not aliases.
|
|
56
|
+
*/
|
|
57
|
+
export declare function buildSmartHealthCardPayloadReferences(payloadBytes: Uint8Array): SmartHealthCardPayloadReferences;
|
|
58
|
+
/** Encodes one compact SHC JWS into one or more standard numeric QR payloads. */
|
|
59
|
+
export declare function encodeSmartHealthCardQr(compactJws: string): readonly string[];
|
|
60
|
+
/** Reassembles standard SHC QR payloads regardless of scan order. */
|
|
61
|
+
export declare function decodeSmartHealthCardQr(qrValues: readonly string[]): string;
|
|
62
|
+
/** Encodes an RFC 7797 ML-DSA detached JWS as the version-one `pqc:/` companion transport. */
|
|
63
|
+
export declare function encodePostQuantumCompanionProofUri(detachedJws: string): string;
|
|
64
|
+
export declare function decodePostQuantumCompanionProofUri(uri: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Encodes an ML-DSA detached JWS using the same two-decimal-digits-per-JWS-
|
|
67
|
+
* character technique as SHC. The external SHC payload is never repeated.
|
|
68
|
+
*/
|
|
69
|
+
export declare function encodePostQuantumCompanionProofQr(detachedJws: string, options?: PostQuantumCompanionProofQrOptions): readonly string[];
|
|
70
|
+
/** Reassembles one complete detached proof regardless of companion QR scan order. */
|
|
71
|
+
export declare function decodePostQuantumCompanionProofQr(qrValues: readonly string[]): string;
|
|
72
|
+
/** Returns the exact uncompressed payload bytes that every detached companion proof verifies. */
|
|
73
|
+
export declare function decodeSmartHealthCardPayloadBytes(compactJws: string): Uint8Array;
|
package/dist/shc.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { canonicalizeFhirResource } from 'gdc-common-utils-ts/utils/fhir-cid';
|
|
2
|
+
import { Content } from 'gdc-common-utils-ts/utils/content';
|
|
3
|
+
import { buildRawCidV1FromUtf8String } from 'gdc-common-utils-ts/utils/multiformat-profile';
|
|
4
|
+
import { encodeMultibaseSha3 } from 'gdc-common-utils-ts/utils/multibasehash';
|
|
5
|
+
import { deflateRaw, inflateRaw } from 'pako';
|
|
6
|
+
export const SmartHealthCardQrPrefix = 'shc:/';
|
|
7
|
+
export const PostQuantumCompanionProofPrefix = 'pqc:/';
|
|
8
|
+
export const SmartHealthCardMaxSingleQrLength = 1195;
|
|
9
|
+
export const SmartHealthCardMaxQrChunkBodyLength = 1191;
|
|
10
|
+
export const PostQuantumCompanionProofDefaultQrCount = 2;
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function nonEmptyString(value, errorCode) {
|
|
15
|
+
if (typeof value !== 'string' || !value.trim())
|
|
16
|
+
throw new TypeError(errorCode);
|
|
17
|
+
return value.trim();
|
|
18
|
+
}
|
|
19
|
+
function collectBundleReferences(bundle) {
|
|
20
|
+
const references = new Map();
|
|
21
|
+
const entries = Array.isArray(bundle.entry) ? bundle.entry : [];
|
|
22
|
+
entries.forEach((entry, index) => {
|
|
23
|
+
if (!isRecord(entry) || !isRecord(entry.resource))
|
|
24
|
+
return;
|
|
25
|
+
const shortReference = `resource:${index}`;
|
|
26
|
+
if (typeof entry.fullUrl === 'string')
|
|
27
|
+
references.set(entry.fullUrl, shortReference);
|
|
28
|
+
if (typeof entry.resource.resourceType === 'string' && typeof entry.resource.id === 'string') {
|
|
29
|
+
references.set(`${entry.resource.resourceType}/${entry.resource.id}`, shortReference);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return references;
|
|
33
|
+
}
|
|
34
|
+
function minimizeValue(value, references, resourceRoot = false) {
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value.map(item => minimizeValue(item, references));
|
|
37
|
+
if (!isRecord(value))
|
|
38
|
+
return value;
|
|
39
|
+
const output = {};
|
|
40
|
+
const isResource = resourceRoot || typeof value.resourceType === 'string';
|
|
41
|
+
const isCoding = typeof value.system === 'string' && typeof value.code === 'string';
|
|
42
|
+
const isCodeableConcept = Array.isArray(value.coding);
|
|
43
|
+
for (const [key, child] of Object.entries(value)) {
|
|
44
|
+
if (isResource && (key === 'id' || key === 'text'))
|
|
45
|
+
continue;
|
|
46
|
+
if (isResource && key === 'meta') {
|
|
47
|
+
if (isRecord(child) && Array.isArray(child.security)) {
|
|
48
|
+
output.meta = { security: minimizeValue(child.security, references) };
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (isCoding && key === 'display')
|
|
53
|
+
continue;
|
|
54
|
+
if (isCodeableConcept && key === 'text')
|
|
55
|
+
continue;
|
|
56
|
+
if (key === 'reference' && typeof child === 'string' && references.has(child)) {
|
|
57
|
+
output.reference = references.get(child);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
output[key] = minimizeValue(child, references);
|
|
61
|
+
}
|
|
62
|
+
return output;
|
|
63
|
+
}
|
|
64
|
+
/** Applies the payload-size rules required by the SMART Health Cards framework. */
|
|
65
|
+
export function minimizeFhirBundleForSmartHealthCard(input) {
|
|
66
|
+
if (input.resourceType !== 'Bundle')
|
|
67
|
+
throw new TypeError('shc_fhir_bundle_required');
|
|
68
|
+
const references = collectBundleReferences(input);
|
|
69
|
+
const minimized = minimizeValue(input, references, true);
|
|
70
|
+
const entries = Array.isArray(minimized.entry) ? minimized.entry : [];
|
|
71
|
+
minimized.entry = entries.map((entry, index) => isRecord(entry)
|
|
72
|
+
? { ...entry, fullUrl: `resource:${index}` }
|
|
73
|
+
: entry);
|
|
74
|
+
return JSON.parse(canonicalizeFhirResource(minimized, {
|
|
75
|
+
stripMetaVersionId: true,
|
|
76
|
+
stripNarrativeText: true,
|
|
77
|
+
stripNestedElementIds: true,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
/** Prepares the exact raw-DEFLATE compact-JWS input to be signed by an external ES256 issuer key. */
|
|
81
|
+
export function prepareSmartHealthCard(input) {
|
|
82
|
+
const issuer = nonEmptyString(input.issuer, 'shc_issuer_required');
|
|
83
|
+
if (!issuer.startsWith('https://') || issuer.endsWith('/'))
|
|
84
|
+
throw new TypeError('shc_issuer_https_without_trailing_slash_required');
|
|
85
|
+
if (!Number.isInteger(input.notBefore) || input.notBefore < 0)
|
|
86
|
+
throw new TypeError('shc_not_before_seconds_required');
|
|
87
|
+
const kid = nonEmptyString(input.kid, 'shc_kid_required');
|
|
88
|
+
const fhirVersion = nonEmptyString(input.fhirVersion, 'shc_fhir_version_required');
|
|
89
|
+
const payload = {
|
|
90
|
+
iss: issuer,
|
|
91
|
+
nbf: input.notBefore,
|
|
92
|
+
vc: {
|
|
93
|
+
type: ['https://smarthealth.cards#health-card', ...(input.additionalTypes ?? [])],
|
|
94
|
+
credentialSubject: {
|
|
95
|
+
fhirVersion,
|
|
96
|
+
fhirBundle: minimizeFhirBundleForSmartHealthCard(input.fhirBundle),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const protectedHeader = { alg: 'ES256', zip: 'DEF', kid };
|
|
101
|
+
const encodedHeader = Content.objectToRawBase64UrlSafe(protectedHeader);
|
|
102
|
+
const canonicalPayload = canonicalizeFhirResource(payload, {
|
|
103
|
+
stripMetaVersionId: false,
|
|
104
|
+
});
|
|
105
|
+
const payloadBytes = Content.stringToBytesUTF8(canonicalPayload);
|
|
106
|
+
const encodedPayload = Content.bytesToRawBase64UrlSafe(deflateRaw(payloadBytes));
|
|
107
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
108
|
+
return {
|
|
109
|
+
protectedHeader,
|
|
110
|
+
payload: JSON.parse(canonicalPayload),
|
|
111
|
+
payloadBytes,
|
|
112
|
+
encodedHeader,
|
|
113
|
+
encodedPayload,
|
|
114
|
+
signingInput,
|
|
115
|
+
signingBytes: Content.stringToBytesUTF8(signingInput),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function assembleSmartHealthCardJws(prepared, signatureBase64Url) {
|
|
119
|
+
const signature = nonEmptyString(signatureBase64Url, 'shc_signature_required');
|
|
120
|
+
if (!/^[A-Za-z0-9_-]+$/.test(signature))
|
|
121
|
+
throw new TypeError('shc_signature_base64url_required');
|
|
122
|
+
return `${prepared.encodedHeader}.${prepared.encodedPayload}.${signature}`;
|
|
123
|
+
}
|
|
124
|
+
function encodeNumericBody(value) {
|
|
125
|
+
return [...value].map(character => {
|
|
126
|
+
const encoded = character.charCodeAt(0) - 45;
|
|
127
|
+
if (encoded < 0 || encoded > 99)
|
|
128
|
+
throw new TypeError('shc_jws_character_invalid');
|
|
129
|
+
return encoded.toString().padStart(2, '0');
|
|
130
|
+
}).join('');
|
|
131
|
+
}
|
|
132
|
+
function decodeNumericBody(value) {
|
|
133
|
+
if (!value || value.length % 2 !== 0 || !/^\d+$/.test(value))
|
|
134
|
+
throw new TypeError('shc_qr_numeric_body_invalid');
|
|
135
|
+
let decoded = '';
|
|
136
|
+
for (let index = 0; index < value.length; index += 2) {
|
|
137
|
+
const pair = Number(value.slice(index, index + 2));
|
|
138
|
+
if (pair > 77)
|
|
139
|
+
throw new TypeError('shc_qr_numeric_pair_invalid');
|
|
140
|
+
decoded += String.fromCharCode(pair + 45);
|
|
141
|
+
}
|
|
142
|
+
return decoded;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Builds both project-supported base58btc references over the exact canonical
|
|
146
|
+
* SHC payload bytes. The multihash URN is a ledger lookup key; the CID is a
|
|
147
|
+
* content identifier. They are deliberately not aliases.
|
|
148
|
+
*/
|
|
149
|
+
export function buildSmartHealthCardPayloadReferences(payloadBytes) {
|
|
150
|
+
if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length === 0) {
|
|
151
|
+
throw new TypeError('shc_payload_bytes_required');
|
|
152
|
+
}
|
|
153
|
+
const payloadText = Content.bytesToStringUTF8(payloadBytes);
|
|
154
|
+
return {
|
|
155
|
+
multihashUrn: `urn:multibase:${encodeMultibaseSha3(payloadBytes, 384)}`,
|
|
156
|
+
cidV1: buildRawCidV1FromUtf8String(payloadText),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Encodes one compact SHC JWS into one or more standard numeric QR payloads. */
|
|
160
|
+
export function encodeSmartHealthCardQr(compactJws) {
|
|
161
|
+
const jws = nonEmptyString(compactJws, 'shc_compact_jws_required');
|
|
162
|
+
if (jws.length <= SmartHealthCardMaxSingleQrLength)
|
|
163
|
+
return [`${SmartHealthCardQrPrefix}${encodeNumericBody(jws)}`];
|
|
164
|
+
const chunkCount = Math.ceil(jws.length / SmartHealthCardMaxQrChunkBodyLength);
|
|
165
|
+
const chunkLength = Math.ceil(jws.length / chunkCount);
|
|
166
|
+
return Array.from({ length: chunkCount }, (_, index) => {
|
|
167
|
+
const chunk = jws.slice(index * chunkLength, (index + 1) * chunkLength);
|
|
168
|
+
return `${SmartHealthCardQrPrefix}${index + 1}/${chunkCount}/${encodeNumericBody(chunk)}`;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Reassembles standard SHC QR payloads regardless of scan order. */
|
|
172
|
+
export function decodeSmartHealthCardQr(qrValues) {
|
|
173
|
+
if (!Array.isArray(qrValues) || qrValues.length === 0)
|
|
174
|
+
throw new TypeError('shc_qr_required');
|
|
175
|
+
if (qrValues.length === 1 && /^shc:\/\d+$/.test(qrValues[0])) {
|
|
176
|
+
return decodeNumericBody(qrValues[0].slice(SmartHealthCardQrPrefix.length));
|
|
177
|
+
}
|
|
178
|
+
const chunks = qrValues.map(value => {
|
|
179
|
+
const match = /^shc:\/(\d+)\/(\d+)\/(\d+)$/.exec(value);
|
|
180
|
+
if (!match)
|
|
181
|
+
throw new TypeError('shc_qr_chunk_invalid');
|
|
182
|
+
return { index: Number(match[1]), count: Number(match[2]), value: decodeNumericBody(match[3]) };
|
|
183
|
+
});
|
|
184
|
+
const expectedCount = chunks[0].count;
|
|
185
|
+
if (expectedCount !== chunks.length
|
|
186
|
+
|| chunks.some(chunk => chunk.count !== expectedCount || chunk.index < 1 || chunk.index > expectedCount)
|
|
187
|
+
|| new Set(chunks.map(chunk => chunk.index)).size !== expectedCount) {
|
|
188
|
+
throw new TypeError('shc_qr_chunk_set_invalid');
|
|
189
|
+
}
|
|
190
|
+
return chunks.sort((left, right) => left.index - right.index).map(chunk => chunk.value).join('');
|
|
191
|
+
}
|
|
192
|
+
function validatePostQuantumDetachedJws(detachedJws) {
|
|
193
|
+
const match = /^([A-Za-z0-9_-]+)\.\.([A-Za-z0-9_-]+)$/.exec(detachedJws);
|
|
194
|
+
if (!match)
|
|
195
|
+
throw new TypeError('pqc_detached_jws_invalid');
|
|
196
|
+
let header;
|
|
197
|
+
try {
|
|
198
|
+
header = Content.base64UrlSafeToJSON(match[1]);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new TypeError('pqc_detached_jws_invalid');
|
|
202
|
+
}
|
|
203
|
+
if (!isRecord(header)
|
|
204
|
+
|| !['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87'].includes(String(header.alg))
|
|
205
|
+
|| typeof header.kid !== 'string' || !header.kid
|
|
206
|
+
|| header.b64 !== false
|
|
207
|
+
|| !Array.isArray(header.crit) || !header.crit.includes('b64')) {
|
|
208
|
+
throw new TypeError('pqc_detached_jws_invalid');
|
|
209
|
+
}
|
|
210
|
+
return detachedJws;
|
|
211
|
+
}
|
|
212
|
+
/** Encodes an RFC 7797 ML-DSA detached JWS as the version-one `pqc:/` companion transport. */
|
|
213
|
+
export function encodePostQuantumCompanionProofUri(detachedJws) {
|
|
214
|
+
const validated = validatePostQuantumDetachedJws(detachedJws);
|
|
215
|
+
return `${PostQuantumCompanionProofPrefix}${validated}`;
|
|
216
|
+
}
|
|
217
|
+
export function decodePostQuantumCompanionProofUri(uri) {
|
|
218
|
+
if (typeof uri !== 'string' || !uri.startsWith(PostQuantumCompanionProofPrefix)) {
|
|
219
|
+
throw new TypeError('pqc_companion_proof_uri_invalid');
|
|
220
|
+
}
|
|
221
|
+
return validatePostQuantumDetachedJws(uri.slice(PostQuantumCompanionProofPrefix.length));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Encodes an ML-DSA detached JWS using the same two-decimal-digits-per-JWS-
|
|
225
|
+
* character technique as SHC. The external SHC payload is never repeated.
|
|
226
|
+
*/
|
|
227
|
+
export function encodePostQuantumCompanionProofQr(detachedJws, options = {}) {
|
|
228
|
+
const validated = validatePostQuantumDetachedJws(detachedJws);
|
|
229
|
+
const qrCount = options.qrCount ?? PostQuantumCompanionProofDefaultQrCount;
|
|
230
|
+
if (!Number.isInteger(qrCount) || qrCount < 1 || qrCount > 99 || qrCount > validated.length) {
|
|
231
|
+
throw new TypeError('pqc_companion_proof_qr_count_invalid');
|
|
232
|
+
}
|
|
233
|
+
const chunkLength = Math.ceil(validated.length / qrCount);
|
|
234
|
+
if (qrCount === 1)
|
|
235
|
+
return [`${PostQuantumCompanionProofPrefix}${encodeNumericBody(validated)}`];
|
|
236
|
+
return Array.from({ length: qrCount }, (_, index) => {
|
|
237
|
+
const chunk = validated.slice(index * chunkLength, (index + 1) * chunkLength);
|
|
238
|
+
return `${PostQuantumCompanionProofPrefix}${index + 1}/${qrCount}/${encodeNumericBody(chunk)}`;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Reassembles one complete detached proof regardless of companion QR scan order. */
|
|
242
|
+
export function decodePostQuantumCompanionProofQr(qrValues) {
|
|
243
|
+
if (!Array.isArray(qrValues) || qrValues.length === 0)
|
|
244
|
+
throw new TypeError('pqc_companion_proof_qr_required');
|
|
245
|
+
if (qrValues.length === 1 && /^pqc:\/\d+$/.test(qrValues[0])) {
|
|
246
|
+
return validatePostQuantumDetachedJws(decodeNumericBody(qrValues[0].slice(PostQuantumCompanionProofPrefix.length)));
|
|
247
|
+
}
|
|
248
|
+
const chunks = qrValues.map(value => {
|
|
249
|
+
const match = /^pqc:\/(\d+)\/(\d+)\/(\d+)$/.exec(value);
|
|
250
|
+
if (!match)
|
|
251
|
+
throw new TypeError('pqc_companion_proof_qr_chunk_invalid');
|
|
252
|
+
return { index: Number(match[1]), count: Number(match[2]), value: decodeNumericBody(match[3]) };
|
|
253
|
+
});
|
|
254
|
+
const expectedCount = chunks[0].count;
|
|
255
|
+
if (expectedCount !== chunks.length
|
|
256
|
+
|| chunks.some(chunk => chunk.count !== expectedCount || chunk.index < 1 || chunk.index > expectedCount)
|
|
257
|
+
|| new Set(chunks.map(chunk => chunk.index)).size !== expectedCount) {
|
|
258
|
+
throw new TypeError('pqc_companion_proof_qr_chunk_set_invalid');
|
|
259
|
+
}
|
|
260
|
+
return validatePostQuantumDetachedJws(chunks.sort((left, right) => left.index - right.index).map(chunk => chunk.value).join(''));
|
|
261
|
+
}
|
|
262
|
+
/** Returns the exact uncompressed payload bytes that every detached companion proof verifies. */
|
|
263
|
+
export function decodeSmartHealthCardPayloadBytes(compactJws) {
|
|
264
|
+
const parts = compactJws.split('.');
|
|
265
|
+
if (parts.length !== 3)
|
|
266
|
+
throw new TypeError('shc_compact_jws_invalid');
|
|
267
|
+
return inflateRaw(Content.base64ToBytes(parts[1]));
|
|
268
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vet-data-utils-ts",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "Browser-safe governed VetChain data contracts",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -40,6 +40,14 @@
|
|
|
40
40
|
"types": "./dist/group.d.ts",
|
|
41
41
|
"default": "./dist/group.js"
|
|
42
42
|
},
|
|
43
|
+
"./immunization": {
|
|
44
|
+
"types": "./dist/immunization.d.ts",
|
|
45
|
+
"default": "./dist/immunization.js"
|
|
46
|
+
},
|
|
47
|
+
"./international-health-card": {
|
|
48
|
+
"types": "./dist/international-health-card.d.ts",
|
|
49
|
+
"default": "./dist/international-health-card.js"
|
|
50
|
+
},
|
|
43
51
|
"./iso-jurisdictions": {
|
|
44
52
|
"types": "./dist/iso-jurisdictions.d.ts",
|
|
45
53
|
"default": "./dist/iso-jurisdictions.js"
|
|
@@ -60,6 +68,10 @@
|
|
|
60
68
|
"types": "./dist/sectors.d.ts",
|
|
61
69
|
"default": "./dist/sectors.js"
|
|
62
70
|
},
|
|
71
|
+
"./shc": {
|
|
72
|
+
"types": "./dist/shc.d.ts",
|
|
73
|
+
"default": "./dist/shc.js"
|
|
74
|
+
},
|
|
63
75
|
"./veterinary-sections": {
|
|
64
76
|
"types": "./dist/veterinary-sections.d.ts",
|
|
65
77
|
"default": "./dist/veterinary-sections.js"
|
|
@@ -85,10 +97,12 @@
|
|
|
85
97
|
},
|
|
86
98
|
"devDependencies": {
|
|
87
99
|
"@types/node": "^22.0.0",
|
|
100
|
+
"@types/pako": "^2.0.4",
|
|
88
101
|
"typescript": "^5.5.4"
|
|
89
102
|
},
|
|
90
103
|
"dependencies": {
|
|
91
|
-
"gdc-common-utils-ts": "2.9.10"
|
|
104
|
+
"gdc-common-utils-ts": "2.9.10",
|
|
105
|
+
"pako": "^2.2.0"
|
|
92
106
|
},
|
|
93
107
|
"engines": {
|
|
94
108
|
"node": ">=20"
|