diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/brown-buses-cheat.md b/.changeset/brown-buses-cheat.md new file mode 100644 index 0000000..c15e11d --- /dev/null +++ b/.changeset/brown-buses-cheat.md @@ -0,0 +1,5 @@ +--- +"@ssecd/ihs": patch +--- + +Initial release diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..91b6a95 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.env.example b/.env.example index 14f6165..164d9e4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ IHS_ORGANIZATION_ID= IHS_CLIENT_SECRET= IHS_SECRET_KEY= +IHS_KYC_PEM_FILE= + +TEST_AGENT_NIK= +TEST_AGENT_NAME= +TEST_PATIENT_NIK= +TEST_PATEINT_NAME= +TEST_PATIENT_ID= diff --git a/.gitignore b/.gitignore index b740314..603cf13 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .env .env.* !.env.example +*.pem diff --git a/README.md b/README.md index 55890b6..a3cf50a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,241 @@ -# IHS +Indonesia Health Service API Helpers -Work in Progress Indonesia Health Service API Helpers +- ✅ FHIR API +- ✅ Patient Consent API +- ✅ KYC API +- ✅ Automatic authentication and token invalidation +- ✅ TypeSafe and Autocomplete-Enabled API + +## Instalasi + +Instalasi paket dapat dilakukan dengan perintah berikut: + +```bash +npm install @ssecd/ihs +``` + +Untuk dukungan _type safe_ pada API FHIR, perlu menambahkan development dependensi `@types/fhir` dengan perintah: + +```bash +npm install --save-dev @types/fhir +``` + +Instalasi juga dapat dilakukan menggunakan `PNPM` atau `YARN` + +## Penggunaan + +### Inisialisasi + +Penggunaan paket ini sangatlah sederhana, cukup menginisialisasi global instansi pada sebuah modul atau file seperti berikut: + +```ts +// file: ihs.ts atau ihs.js +import IHS from '@ssecd/ihs'; + +const ihs = new IHS(); + +export default ihs; +``` + +Secara default konfigurasi seperti **Client Secret** atau **Secret Key** akan dibaca melalui environment variable namun konfigurasi juga dapat diatur pada constructor class seperti berikut: + +```ts +const ihs = new IHS({ + clientSecret: '', + kycPemFile: `/home/user/kyc-publickey.pem` + // dan seterusnya +}); +``` + +Selain menggunakan objek, konfigurasi juga dapat diatur menggunakan fungsi, misalnya pada kasus membaca atau mendapatkan konfigurasi dari database: + +```ts +const ihs = new IHS(async () => { + const config = await sql`select * from config`; + return { + clientSecret: config.clientSecret + // dan seterusnya ... + }; +}); +``` + +Perlu diperhatikan bahwa fungsi config pada constructor parameter tersebut hanya akan dipanggil satu kali. Bila terjadi perubahan konfigurasi harap memanggil fungsi `invalidateConfig()` pada instansi IHS untuk memperbaharui atau menerapkan perubahan konfigurasi. + +```ts +await ihs.invalidateConfig(); +``` + +Konfigurasi lengkapnya dapat dilihat di bagian [Konfigurasi](#konfigurasi). + +### Autentikasi + +Proses autentikasi dan re-autentikasi dilakukan secara otomatis pada saat melakukan request di masing-masing API. Autentikasi terjadi hanya ketika tidak terdapat token atau token sudah kedaluwarsa. + +Meski demikian, autentikasi juga dapat dilakukan secara manual jika sewaktu-waktu memerlukan informasi dari response autentikasi melalui instansi `IHS` dengan memanggil method `auth()` dengan kembalian berupa type `AuthDetail` yang berisi informasi autentikasi termasuk nilai `access_token`, `expires_in`, `issued_at` dan lainnya sesuai dengan spesifikasi IHS. + +```ts +import ihs from './path/to/ihs.js'; + +const detail: AuthDetail = await ihs.auth(); +``` + +### Patient Consent API + +Pada Patient Consent, terdapat dua buah method yang di-definisikan sesuai dengan spesifikasi IHS yakni method untuk mendapatkan informasi consent pasien: + +```ts +const result = await ihs.consent.get('P02478375538'); + +if (result.resourceType === 'Consent') { + console.info(result); // Consent resource +} else { + console.error(result); // OperationOutcome resource +} +``` + +dan method untuk memperbarui informasi consent pasien: + +```ts +const result = await ihs.consent.update({ + patientId: 'P02478375538', + action: 'OPTIN', + agent: 'Nama Agen' +}); + +if (result.resourceType === 'Consent') { + console.info(result); // Consent resource +} else { + console.error(result); // OperationOutcome resource +} +``` + +Setiap method pada Patient Consent API ini memiliki nilai kembalian FHIR resource `Consent` jika request sukses dan `OperationOutcome` jika request gagal. + +### FHIR API + +Pada API ini, implementasi-nya sangat sederhana dan mengutamakan fleksibilitas yakni dengan hanya mengembalikan `Response` object sehingga response sepenuhnya di-_handle_ pengguna. + +```ts +const response: Response = await ihs.fhir(`/Patient`, { + searchParams: [ + ['identifier', 'https://fhir.kemkes.go.id/id/nik|9271060312000001'], + ['gender', 'male'] + ] +}); + +if (response.ok) { + const patientBundle = await response.json(); + console.info(patientBundle); // Bundle +} +``` + +### KYC API + +Pada API ini, terdapat dua buah method yakni method untuk melakukan proses Generate URL Validasi di mana URL digunakan untuk melakukan verifikasi akun SatuSehat melalui SatuSehat Mobile: + +```ts +const result = await ihs.kyc.generateValidationUrl({ + name: 'Nama Agen', + nik: 'NIK Agen' +}); + +if ('error' in result.data) { + console.error(result.data.error); // Request error message +} else { + console.info(result.data); + /* + { + agent_name: string; + agent_nik: string; + token: string; + url: string; + } + */ +} +``` + +dan method untuk melakukan proses Generate Kode Verifikasi di mana nilai tersebut akan muncul di SatuSehat Mobile (SSM) dan digunakan oleh pasien untuk proses validasi: + +```ts +const result = await ihs.kyc.generateVerificationCode({ + nik: 'NIK Pasien', + name: 'Nama Pasien' +}); + +if ('error' in result.data) { + console.error(result.data.error); // Request error message +} else { + console.info(result.data); + /* + { + nik: string; + name: string; + ihs_number: string; + challenge_code: string; + created_timestamp: string; + expired_timestamp: string; + } + */ +} +``` + +Setiap method pada API ini memiliki parameter dan nilai kembalian yang di-definisikan sesuai dengan spesifikasi IHS pada Playbook. + +Proses enkripsi dan dekripsi pesan dilakukan dengan menggunakan algoritma `aes-256-gcm` sedangkan untuk proses enkripsi dan dekripsi _symmetric key_ menggunakan metode RSA dengan `RSA_PKCS1_OAEP_PADDING` padding dan `sha256` hash. Semua proses tersebut sudah dilakukan secara internal sesuai dengan spesifikasi IHS pada Playbook. + +Proses kriptografi pada API ini memerlukan file _server key_ atau _public key_ dengan format `.pem`. File _public key_ ini dapat disesuaikan lokasinya dengan mengatur `kycPemFile` pada config instance atau class `IHS` yang secara default bernama `publickey.dev.pem` pada mode `development` atau `publickey.pem` pada mode `production` dan berada di _working directory_ atau folder di mana API dijalankan. + +File _public key_ atau _server key_ dapat di-unduh di [sini](https://github.com/ssecd/ihs/issues/2). + +## Konfigurasi + +Konfigurasi mengikuti interface berikut: + +```ts +interface IHSConfig { + /** + * Client secret dari Akses Kode API di Platform SatuSehat + * + * @default process.env.IHS_CLIENT_SECRET + */ + clientSecret: string; + + /** + * Secret key dari Akses Kode API di Platform SatuSehat + * + * @default process.env.IHS_SECRET_KEY + */ + secretKey: string; + + /** + * Mode environment API antara `development` ata `production` + * + * @default process.env.NODE_ENV || 'development' + */ + mode: Mode; + + /** + * Path atau lokasi public key KYC dari SatuSehat. Dapat + * menggunakan absolute atau relative path. Secara default + * akan membaca nilai environment variable IHS_KYC_PEM_FILE + * atau `publickey.dev.pem` pada mode `development` dan + * `publickey.pem` pada mode `production` + * + * @default process.env.IHS_KYC_PEM_FILE + */ + kycPemFile: string; +} +``` + +## Kontribusi + +Kontribusi sangat dipersilakan dan dapat dilakukan dengan berbagai cara seperti melaporkan masalah, membuat permintaan atau menambahkan fitur melalui PR, atau sekedar memperbaiki kesalahan ketikan. + +## Lisensi + +[MIT](./LICENSE) + +## Lainnya + +- [Pemecahan Masalah](https://github.com/ssecd/ihs/issues?q=is%3Aissue) +- [Laporkan Bug](https://github.com/ssecd/ihs/issues/new) diff --git a/package.json b/package.json index 29626a5..2c7204a 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,11 @@ "type": "module", "scripts": { "test": "vitest", - "build": "tsc", + "build": "rollup -c", "lint": "prettier --check . && eslint .", "format": "prettier --write .", - "release": "changeset publish" + "release": "changeset publish", + "prepublishOnly": "dts-buddy" }, "keywords": [ "ihs", @@ -33,26 +34,33 @@ "url": "https://github.com/mustofa-id" }, "license": "MIT", + "module": "./dist/ihs.js", + "types": "./dist/ihs.d.ts", "files": [ + "src", "dist" ], - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + ".": { + "types": "./dist/ihs.d.ts", + "import": "./src/ihs.js" + } }, "devDependencies": { "@changesets/cli": "^2.27.1", - "@types/node": "^20.10.5", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", + "@rollup/plugin-typescript": "^11.1.5", + "@types/node": "^20.10.8", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", "dotenv": "^16.3.1", + "dts-buddy": "^0.4.3", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.1.1", + "rollup": "^4.9.4", + "tslib": "^2.6.2", "typescript": "^5.3.3", - "vitest": "^1.1.0" + "vitest": "^1.1.3" }, "peerDependencies": { "@types/fhir": "^0.0.40" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2785578..429d8aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,18 +13,24 @@ devDependencies: '@changesets/cli': specifier: ^2.27.1 version: 2.27.1 + '@rollup/plugin-typescript': + specifier: ^11.1.5 + version: 11.1.5(rollup@4.9.4)(tslib@2.6.2)(typescript@5.3.3) '@types/node': - specifier: ^20.10.5 - version: 20.10.5 + specifier: ^20.10.8 + version: 20.10.8 '@typescript-eslint/eslint-plugin': - specifier: ^6.15.0 - version: 6.15.0(@typescript-eslint/parser@6.15.0)(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.18.1 + version: 6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': - specifier: ^6.15.0 - version: 6.15.0(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.18.1 + version: 6.18.1(eslint@8.56.0)(typescript@5.3.3) dotenv: specifier: ^16.3.1 version: 16.3.1 + dts-buddy: + specifier: ^0.4.3 + version: 0.4.3(typescript@5.3.3) eslint: specifier: ^8.56.0 version: 8.56.0 @@ -34,12 +40,18 @@ devDependencies: prettier: specifier: ^3.1.1 version: 3.1.1 + rollup: + specifier: ^4.9.4 + version: 4.9.4 + tslib: + specifier: ^2.6.2 + version: 2.6.2 typescript: specifier: ^5.3.3 version: 5.3.3 vitest: - specifier: ^1.1.0 - version: 1.1.0(@types/node@20.10.5) + specifier: ^1.1.3 + version: 1.1.3(@types/node@20.10.8) packages: @@ -531,10 +543,43 @@ packages: '@sinclair/typebox': 0.27.8 dev: true + /@jridgewell/gen-mapping@0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.20 + dev: true + + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/set-array@1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/source-map@0.3.5: + resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.20 + dev: true + /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} dev: true + /@jridgewell/trace-mapping@0.3.20: + resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + /@manypkg/find-root@1.1.0: resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} dependencies: @@ -576,104 +621,139 @@ packages: fastq: 1.16.0 dev: true - /@rollup/rollup-android-arm-eabi@4.9.1: - resolution: {integrity: sha512-6vMdBZqtq1dVQ4CWdhFwhKZL6E4L1dV6jUjuBvsavvNJSppzi6dLBbuV+3+IyUREaj9ZFvQefnQm28v4OCXlig==} + /@rollup/plugin-typescript@11.1.5(rollup@4.9.4)(tslib@2.6.2)(typescript@5.3.3): + resolution: {integrity: sha512-rnMHrGBB0IUEv69Q8/JGRD/n4/n6b3nfpufUu26axhUcboUzv/twfZU8fIBbTOphRAe0v8EyxzeDpKXqGHfyDA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.9.4) + resolve: 1.22.8 + rollup: 4.9.4 + tslib: 2.6.2 + typescript: 5.3.3 + dev: true + + /@rollup/pluginutils@5.1.0(rollup@4.9.4): + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 4.9.4 + dev: true + + /@rollup/rollup-android-arm-eabi@4.9.4: + resolution: {integrity: sha512-ub/SN3yWqIv5CWiAZPHVS1DloyZsJbtXmX4HxUTIpS0BHm9pW5iYBo2mIZi+hE3AeiTzHz33blwSnhdUo+9NpA==} cpu: [arm] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-android-arm64@4.9.1: - resolution: {integrity: sha512-Jto9Fl3YQ9OLsTDWtLFPtaIMSL2kwGyGoVCmPC8Gxvym9TCZm4Sie+cVeblPO66YZsYH8MhBKDMGZ2NDxuk/XQ==} + /@rollup/rollup-android-arm64@4.9.4: + resolution: {integrity: sha512-ehcBrOR5XTl0W0t2WxfTyHCR/3Cq2jfb+I4W+Ch8Y9b5G+vbAecVv0Fx/J1QKktOrgUYsIKxWAKgIpvw56IFNA==} cpu: [arm64] os: [android] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-arm64@4.9.1: - resolution: {integrity: sha512-LtYcLNM+bhsaKAIGwVkh5IOWhaZhjTfNOkGzGqdHvhiCUVuJDalvDxEdSnhFzAn+g23wgsycmZk1vbnaibZwwA==} + /@rollup/rollup-darwin-arm64@4.9.4: + resolution: {integrity: sha512-1fzh1lWExwSTWy8vJPnNbNM02WZDS8AW3McEOb7wW+nPChLKf3WG2aG7fhaUmfX5FKw9zhsF5+MBwArGyNM7NA==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-darwin-x64@4.9.1: - resolution: {integrity: sha512-KyP/byeXu9V+etKO6Lw3E4tW4QdcnzDG/ake031mg42lob5tN+5qfr+lkcT/SGZaH2PdW4Z1NX9GHEkZ8xV7og==} + /@rollup/rollup-darwin-x64@4.9.4: + resolution: {integrity: sha512-Gc6cukkF38RcYQ6uPdiXi70JB0f29CwcQ7+r4QpfNpQFVHXRd0DfWFidoGxjSx1DwOETM97JPz1RXL5ISSB0pA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.9.1: - resolution: {integrity: sha512-Yqz/Doumf3QTKplwGNrCHe/B2p9xqDghBZSlAY0/hU6ikuDVQuOUIpDP/YcmoT+447tsZTmirmjgG3znvSCR0Q==} + /@rollup/rollup-linux-arm-gnueabihf@4.9.4: + resolution: {integrity: sha512-g21RTeFzoTl8GxosHbnQZ0/JkuFIB13C3T7Y0HtKzOXmoHhewLbVTFBQZu+z5m9STH6FZ7L/oPgU4Nm5ErN2fw==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-gnu@4.9.1: - resolution: {integrity: sha512-u3XkZVvxcvlAOlQJ3UsD1rFvLWqu4Ef/Ggl40WAVCuogf4S1nJPHh5RTgqYFpCOvuGJ7H5yGHabjFKEZGExk5Q==} + /@rollup/rollup-linux-arm64-gnu@4.9.4: + resolution: {integrity: sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-arm64-musl@4.9.1: - resolution: {integrity: sha512-0XSYN/rfWShW+i+qjZ0phc6vZ7UWI8XWNz4E/l+6edFt+FxoEghrJHjX1EY/kcUGCnZzYYRCl31SNdfOi450Aw==} + /@rollup/rollup-linux-arm64-musl@4.9.4: + resolution: {integrity: sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-riscv64-gnu@4.9.1: - resolution: {integrity: sha512-LmYIO65oZVfFt9t6cpYkbC4d5lKHLYv5B4CSHRpnANq0VZUQXGcCPXHzbCXCz4RQnx7jvlYB1ISVNCE/omz5cw==} + /@rollup/rollup-linux-riscv64-gnu@4.9.4: + resolution: {integrity: sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==} cpu: [riscv64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-gnu@4.9.1: - resolution: {integrity: sha512-kr8rEPQ6ns/Lmr/hiw8sEVj9aa07gh1/tQF2Y5HrNCCEPiCBGnBUt9tVusrcBBiJfIt1yNaXN6r1CCmpbFEDpg==} + /@rollup/rollup-linux-x64-gnu@4.9.4: + resolution: {integrity: sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-linux-x64-musl@4.9.1: - resolution: {integrity: sha512-t4QSR7gN+OEZLG0MiCgPqMWZGwmeHhsM4AkegJ0Kiy6TnJ9vZ8dEIwHw1LcZKhbHxTY32hp9eVCMdR3/I8MGRw==} + /@rollup/rollup-linux-x64-musl@4.9.4: + resolution: {integrity: sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-arm64-msvc@4.9.1: - resolution: {integrity: sha512-7XI4ZCBN34cb+BH557FJPmh0kmNz2c25SCQeT9OiFWEgf8+dL6ZwJ8f9RnUIit+j01u07Yvrsuu1rZGxJCc51g==} + /@rollup/rollup-win32-arm64-msvc@4.9.4: + resolution: {integrity: sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-ia32-msvc@4.9.1: - resolution: {integrity: sha512-yE5c2j1lSWOH5jp+Q0qNL3Mdhr8WuqCNVjc6BxbVfS5cAS6zRmdiw7ktb8GNpDCEUJphILY6KACoFoRtKoqNQg==} + /@rollup/rollup-win32-ia32-msvc@4.9.4: + resolution: {integrity: sha512-z+JQ7JirDUHAsMecVydnBPWLwJjbppU+7LZjffGf+Jvrxq+dVjIE7By163Sc9DKc3ADSU50qPVw0KonBS+a+HQ==} cpu: [ia32] os: [win32] requiresBuild: true dev: true optional: true - /@rollup/rollup-win32-x64-msvc@4.9.1: - resolution: {integrity: sha512-PyJsSsafjmIhVgaI1Zdj7m8BB8mMckFah/xbpplObyHfiXzKcI5UOUXRyOdHW7nz4DpMCuzLnF7v5IWHenCwYA==} + /@rollup/rollup-win32-x64-msvc@4.9.4: + resolution: {integrity: sha512-LfdGXCV9rdEify1oxlN9eamvDSjv9md9ZVMAbNHA87xqIfFCxImxan9qZ8+Un54iK2nnqPlbnSi4R54ONtbWBw==} cpu: [x64] os: [win32] requiresBuild: true @@ -684,6 +764,10 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + /@types/fhir@0.0.40: resolution: {integrity: sha512-ae00uDa0GrgPl4sDsGpHEdUjxCeot0UEEhgO/4PljimMKrPMyEVMZpsiAjwCp+dARn7zybOflnLK+nfORLjxDw==} dev: false @@ -700,8 +784,8 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true - /@types/node@20.10.5: - resolution: {integrity: sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==} + /@types/node@20.10.8: + resolution: {integrity: sha512-f8nQs3cLxbAFc00vEU59yf9UyGUftkPaLGfvbVOIDdx2i1b8epBqj2aNGyP19fiyXWvlmZ7qC1XLjAzw/OKIeA==} dependencies: undici-types: 5.26.5 dev: true @@ -714,8 +798,8 @@ packages: resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} dev: true - /@typescript-eslint/eslint-plugin@6.15.0(@typescript-eslint/parser@6.15.0)(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-j5qoikQqPccq9QoBAupOP+CBu8BaJ8BLjaXSioDISeTZkVO3ig7oSIKh3H+rEpee7xCXtWwSB4KIL5l6hWZzpg==} + /@typescript-eslint/eslint-plugin@6.18.1(@typescript-eslint/parser@6.18.1)(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-nISDRYnnIpk7VCFrGcu1rnZfM1Dh9LRHnfgdkjcbi/l7g16VYRri3TjXi9Ir4lOZSw5N/gnV/3H7jIPQ8Q4daA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -726,11 +810,11 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/type-utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/parser': 6.18.1(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/type-utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.18.1 debug: 4.3.4 eslint: 8.56.0 graphemer: 1.4.0 @@ -743,8 +827,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-MkgKNnsjC6QwcMdlNAel24jjkEO/0hQaMDLqP4S9zq5HBAUJNQB6y+3DwLjX7b3l2b37eNAxMPLwb3/kh8VKdA==} + /@typescript-eslint/parser@6.18.1(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-zct/MdJnVaRRNy9e84XnVtRv9Vf91/qqe+hZJtKanjojud4wAVy/7lXxJmMyX6X6J+xc6c//YEWvpeif8cAhWA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -753,10 +837,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.18.1 debug: 4.3.4 eslint: 8.56.0 typescript: 5.3.3 @@ -764,16 +848,16 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@6.15.0: - resolution: {integrity: sha512-+BdvxYBltqrmgCNu4Li+fGDIkW9n//NrruzG9X1vBzaNK+ExVXPoGB71kneaVw/Jp+4rH/vaMAGC6JfMbHstVg==} + /@typescript-eslint/scope-manager@6.18.1: + resolution: {integrity: sha512-BgdBwXPFmZzaZUuw6wKiHKIovms97a7eTImjkXCZE04TGHysG+0hDQPmygyvgtkoB/aOQwSM/nWv3LzrOIQOBw==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/visitor-keys': 6.18.1 dev: true - /@typescript-eslint/type-utils@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-CnmHKTfX6450Bo49hPg2OkIm/D/TVYV7jO1MCfPYGwf6x3GO0VU8YMO5AYMn+u3X05lRRxA4fWCz87GFQV6yVQ==} + /@typescript-eslint/type-utils@6.18.1(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-wyOSKhuzHeU/5pcRDP2G2Ndci+4g653V43gXTpt4nbyoIOAASkGDA9JIAgbQCdCkcr1MvpSYWzxTz0olCn8+/Q==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -782,8 +866,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.15.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) + '@typescript-eslint/utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) debug: 4.3.4 eslint: 8.56.0 ts-api-utils: 1.0.3(typescript@5.3.3) @@ -792,13 +876,13 @@ packages: - supports-color dev: true - /@typescript-eslint/types@6.15.0: - resolution: {integrity: sha512-yXjbt//E4T/ee8Ia1b5mGlbNj9fB9lJP4jqLbZualwpP2BCQ5is6BcWwxpIsY4XKAhmdv3hrW92GdtJbatC6dQ==} + /@typescript-eslint/types@6.18.1: + resolution: {integrity: sha512-4TuMAe+tc5oA7wwfqMtB0Y5OrREPF1GeJBAjqwgZh1lEMH5PJQgWgHGfYufVB51LtjD+peZylmeyxUXPfENLCw==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.15.0(typescript@5.3.3): - resolution: {integrity: sha512-7mVZJN7Hd15OmGuWrp2T9UvqR2Ecg+1j/Bp1jXUEY2GZKV6FXlOIoqVDmLpBiEiq3katvj/2n2mR0SDwtloCew==} + /@typescript-eslint/typescript-estree@6.18.1(typescript@5.3.3): + resolution: {integrity: sha512-fv9B94UAhywPRhUeeV/v+3SBDvcPiLxRZJw/xZeeGgRLQZ6rLMG+8krrJUyIf6s1ecWTzlsbp0rlw7n9sjufHA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -806,11 +890,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/visitor-keys': 6.15.0 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/visitor-keys': 6.18.1 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 + minimatch: 9.0.3 semver: 7.5.4 ts-api-utils: 1.0.3(typescript@5.3.3) typescript: 5.3.3 @@ -818,8 +903,8 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.15.0(eslint@8.56.0)(typescript@5.3.3): - resolution: {integrity: sha512-eF82p0Wrrlt8fQSRL0bGXzK5nWPRV2dYQZdajcfzOD9+cQz9O7ugifrJxclB+xVOvWvagXfqS4Es7vpLP4augw==} + /@typescript-eslint/utils@6.18.1(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-zZmTuVZvD1wpoceHvoQpOiewmWu3uP9FuTWo8vqpy2ffsmfCE8mklRPi+vmnIYAIk9t/4kOThri2QCDgor+OpQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -827,9 +912,9 @@ packages: '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.6 - '@typescript-eslint/scope-manager': 6.15.0 - '@typescript-eslint/types': 6.15.0 - '@typescript-eslint/typescript-estree': 6.15.0(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.18.1 + '@typescript-eslint/types': 6.18.1 + '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) eslint: 8.56.0 semver: 7.5.4 transitivePeerDependencies: @@ -837,11 +922,11 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys@6.15.0: - resolution: {integrity: sha512-1zvtdC1a9h5Tb5jU9x3ADNXO9yjP8rXlaoChu0DQX40vf5ACVpYIVIZhIMZ6d5sDXH7vq4dsZBT1fEGj8D2n2w==} + /@typescript-eslint/visitor-keys@6.18.1: + resolution: {integrity: sha512-/kvt0C5lRqGoCfsbmm7/CwMqoSkY3zzHLIjdhHZQW3VFrnz7ATecOHR7nb7V+xn4286MBxfnQfQhAmCI0u+bJA==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.15.0 + '@typescript-eslint/types': 6.18.1 eslint-visitor-keys: 3.4.3 dev: true @@ -849,40 +934,41 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitest/expect@1.1.0: - resolution: {integrity: sha512-9IE2WWkcJo2BR9eqtY5MIo3TPmS50Pnwpm66A6neb2hvk/QSLfPXBz2qdiwUOQkwyFuuXEUj5380CbwfzW4+/w==} + /@vitest/expect@1.1.3: + resolution: {integrity: sha512-MnJqsKc1Ko04lksF9XoRJza0bGGwTtqfbyrsYv5on4rcEkdo+QgUdITenBQBUltKzdxW7K3rWh+nXRULwsdaVg==} dependencies: - '@vitest/spy': 1.1.0 - '@vitest/utils': 1.1.0 + '@vitest/spy': 1.1.3 + '@vitest/utils': 1.1.3 chai: 4.3.10 dev: true - /@vitest/runner@1.1.0: - resolution: {integrity: sha512-zdNLJ00pm5z/uhbWF6aeIJCGMSyTyWImy3Fcp9piRGvueERFlQFbUwCpzVce79OLm2UHk9iwaMSOaU9jVHgNVw==} + /@vitest/runner@1.1.3: + resolution: {integrity: sha512-Va2XbWMnhSdDEh/OFxyUltgQuuDRxnarK1hW5QNN4URpQrqq6jtt8cfww/pQQ4i0LjoYxh/3bYWvDFlR9tU73g==} dependencies: - '@vitest/utils': 1.1.0 + '@vitest/utils': 1.1.3 p-limit: 5.0.0 pathe: 1.1.1 dev: true - /@vitest/snapshot@1.1.0: - resolution: {integrity: sha512-5O/wyZg09V5qmNmAlUgCBqflvn2ylgsWJRRuPrnHEfDNT6tQpQ8O1isNGgo+VxofISHqz961SG3iVvt3SPK/QQ==} + /@vitest/snapshot@1.1.3: + resolution: {integrity: sha512-U0r8pRXsLAdxSVAyGNcqOU2H3Z4Y2dAAGGelL50O0QRMdi1WWeYHdrH/QWpN1e8juWfVKsb8B+pyJwTC+4Gy9w==} dependencies: magic-string: 0.30.5 pathe: 1.1.1 pretty-format: 29.7.0 dev: true - /@vitest/spy@1.1.0: - resolution: {integrity: sha512-sNOVSU/GE+7+P76qYo+VXdXhXffzWZcYIPQfmkiRxaNCSPiLANvQx5Mx6ZURJ/ndtEkUJEpvKLXqAYTKEY+lTg==} + /@vitest/spy@1.1.3: + resolution: {integrity: sha512-Ec0qWyGS5LhATFQtldvChPTAHv08yHIOZfiNcjwRQbFPHpkih0md9KAbs7TfeIfL7OFKoe7B/6ukBTqByubXkQ==} dependencies: tinyspy: 2.2.0 dev: true - /@vitest/utils@1.1.0: - resolution: {integrity: sha512-z+s510fKmYz4Y41XhNs3vcuFTFhcij2YF7F8VQfMEYAAUfqQh0Zfg7+w9xdgFGhPf3tX3TicAe+8BDITk6ampQ==} + /@vitest/utils@1.1.3: + resolution: {integrity: sha512-Dyt3UMcdElTll2H75vhxfpZu03uFpXRCHxWnzcrFjZxT1kTbq8ALUYIeBgGolo1gldVdI0YSlQRacsqxTwNqwg==} dependencies: diff-sequences: 29.6.3 + estree-walker: 3.0.3 loupe: 2.3.7 pretty-format: 29.7.0 dev: true @@ -1021,6 +1107,12 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} @@ -1286,6 +1378,24 @@ packages: engines: {node: '>=12'} dev: true + /dts-buddy@0.4.3(typescript@5.3.3): + resolution: {integrity: sha512-vytwDCQAj8rqYPbGsrjiOCRv3O2ipwyUwSc5/II1MpS/Eq6KNZNkGU1djOA31nL7jh7092W/nwbwZHCKedf8Vw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.4 <5.4' + dependencies: + '@jridgewell/source-map': 0.3.5 + '@jridgewell/sourcemap-codec': 1.4.15 + globrex: 0.1.2 + kleur: 4.1.5 + locate-character: 3.0.0 + magic-string: 0.30.5 + sade: 1.8.1 + tiny-glob: 0.2.9 + ts-api-utils: 1.0.3(typescript@5.3.3) + typescript: 5.3.3 + dev: true + /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true @@ -1522,6 +1632,16 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1758,6 +1878,10 @@ packages: define-properties: 1.2.1 dev: true + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -1770,6 +1894,10 @@ packages: slash: 3.0.0 dev: true + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -2145,6 +2273,10 @@ packages: pkg-types: 1.0.3 dev: true + /locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + dev: true + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -2254,6 +2386,13 @@ packages: brace-expansion: 1.1.11 dev: true + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -2277,6 +2416,11 @@ packages: ufo: 1.3.2 dev: true + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true @@ -2652,24 +2796,26 @@ packages: glob: 7.2.3 dev: true - /rollup@4.9.1: - resolution: {integrity: sha512-pgPO9DWzLoW/vIhlSoDByCzcpX92bKEorbgXuZrqxByte3JFk2xSW2JEeAcyLc9Ru9pqcNNW+Ob7ntsk2oT/Xw==} + /rollup@4.9.4: + resolution: {integrity: sha512-2ztU7pY/lrQyXSCnnoU4ICjT/tCG9cdH3/G25ERqE3Lst6vl2BCM5hL2Nw+sslAvAf+ccKsAq1SkKQALyqhR7g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + dependencies: + '@types/estree': 1.0.5 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.9.1 - '@rollup/rollup-android-arm64': 4.9.1 - '@rollup/rollup-darwin-arm64': 4.9.1 - '@rollup/rollup-darwin-x64': 4.9.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.9.1 - '@rollup/rollup-linux-arm64-gnu': 4.9.1 - '@rollup/rollup-linux-arm64-musl': 4.9.1 - '@rollup/rollup-linux-riscv64-gnu': 4.9.1 - '@rollup/rollup-linux-x64-gnu': 4.9.1 - '@rollup/rollup-linux-x64-musl': 4.9.1 - '@rollup/rollup-win32-arm64-msvc': 4.9.1 - '@rollup/rollup-win32-ia32-msvc': 4.9.1 - '@rollup/rollup-win32-x64-msvc': 4.9.1 + '@rollup/rollup-android-arm-eabi': 4.9.4 + '@rollup/rollup-android-arm64': 4.9.4 + '@rollup/rollup-darwin-arm64': 4.9.4 + '@rollup/rollup-darwin-x64': 4.9.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.9.4 + '@rollup/rollup-linux-arm64-gnu': 4.9.4 + '@rollup/rollup-linux-arm64-musl': 4.9.4 + '@rollup/rollup-linux-riscv64-gnu': 4.9.4 + '@rollup/rollup-linux-x64-gnu': 4.9.4 + '@rollup/rollup-linux-x64-musl': 4.9.4 + '@rollup/rollup-win32-arm64-msvc': 4.9.4 + '@rollup/rollup-win32-ia32-msvc': 4.9.4 + '@rollup/rollup-win32-x64-msvc': 4.9.4 fsevents: 2.3.3 dev: true @@ -2679,6 +2825,13 @@ packages: queue-microtask: 1.2.3 dev: true + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} engines: {node: '>=0.4'} @@ -2949,6 +3102,13 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + /tinybench@2.5.1: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true @@ -2991,6 +3151,10 @@ packages: typescript: 5.3.3 dev: true + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + dev: true + /tty-table@4.2.3: resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==} engines: {node: '>=8.0.0'} @@ -3116,8 +3280,8 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vite-node@1.1.0(@types/node@20.10.5): - resolution: {integrity: sha512-jV48DDUxGLEBdHCQvxL1mEh7+naVy+nhUUUaPAZLd3FJgXuxQiewHcfeZebbJ6onDqNGkP4r3MhQ342PRlG81Q==} + /vite-node@1.1.3(@types/node@20.10.8): + resolution: {integrity: sha512-BLSO72YAkIUuNrOx+8uznYICJfTEbvBAmWClY3hpath5+h1mbPS5OMn42lrTxXuyCazVyZoDkSRnju78GiVCqA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: @@ -3125,7 +3289,7 @@ packages: debug: 4.3.4 pathe: 1.1.1 picocolors: 1.0.0 - vite: 5.0.10(@types/node@20.10.5) + vite: 5.0.10(@types/node@20.10.8) transitivePeerDependencies: - '@types/node' - less @@ -3137,7 +3301,7 @@ packages: - terser dev: true - /vite@5.0.10(@types/node@20.10.5): + /vite@5.0.10(@types/node@20.10.8): resolution: {integrity: sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -3165,16 +3329,16 @@ packages: terser: optional: true dependencies: - '@types/node': 20.10.5 + '@types/node': 20.10.8 esbuild: 0.19.10 postcss: 8.4.32 - rollup: 4.9.1 + rollup: 4.9.4 optionalDependencies: fsevents: 2.3.3 dev: true - /vitest@1.1.0(@types/node@20.10.5): - resolution: {integrity: sha512-oDFiCrw7dd3Jf06HoMtSRARivvyjHJaTxikFxuqJjO76U436PqlVw1uLn7a8OSPrhSfMGVaRakKpA2lePdw79A==} + /vitest@1.1.3(@types/node@20.10.8): + resolution: {integrity: sha512-2l8om1NOkiA90/Y207PsEvJLYygddsOyr81wLQ20Ra8IlLKbyQncWsGZjnbkyG2KwwuTXLQjEPOJuxGMG8qJBQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3198,12 +3362,12 @@ packages: jsdom: optional: true dependencies: - '@types/node': 20.10.5 - '@vitest/expect': 1.1.0 - '@vitest/runner': 1.1.0 - '@vitest/snapshot': 1.1.0 - '@vitest/spy': 1.1.0 - '@vitest/utils': 1.1.0 + '@types/node': 20.10.8 + '@vitest/expect': 1.1.3 + '@vitest/runner': 1.1.3 + '@vitest/snapshot': 1.1.3 + '@vitest/spy': 1.1.3 + '@vitest/utils': 1.1.3 acorn-walk: 8.3.1 cac: 6.7.14 chai: 4.3.10 @@ -3217,8 +3381,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.5.1 tinypool: 0.8.1 - vite: 5.0.10(@types/node@20.10.5) - vite-node: 1.1.0(@types/node@20.10.5) + vite: 5.0.10(@types/node@20.10.8) + vite-node: 1.1.3(@types/node@20.10.8) why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..3019535 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,12 @@ +import typescript from '@rollup/plugin-typescript'; + +/** @type {import('rollup').RollupOptions} */ +export default { + input: 'src/ihs.ts', + output: { + format: 'esm', + dir: 'dist' + }, + plugins: [typescript()], + external: (id) => /^node:/i.test(id) +}; diff --git a/src/consent.spec.ts b/src/consent.spec.ts new file mode 100644 index 0000000..56e5ac9 --- /dev/null +++ b/src/consent.spec.ts @@ -0,0 +1,45 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { Consent, getConsentSingleton } from './consent.js'; +import IHS from './ihs.js'; + +let consent: Consent; + +beforeAll(() => { + const ihs = new IHS(); + consent = getConsentSingleton(ihs); +}); + +describe('consent', () => { + it('instance should be object and valid', async () => { + expect(consent).toBeTypeOf('object'); + expect(consent).toBeInstanceOf(Consent); + }); + + it('get method should return Consent resource', async () => { + const result = await consent.get(process.env.TEST_PATIENT_ID!); + expect(result.resourceType === 'Consent').toBe(true); + }); + + it('get method should return OperationOutcome resource', async () => { + const result = await consent.get('1'); + expect(result.resourceType === 'OperationOutcome').toBe(true); + }); + + it('update method should return Consent resource', async () => { + const result = await consent.update({ + patientId: process.env.TEST_PATIENT_ID!, + action: 'OPTIN', + agent: process.env.TEST_AGENT_NAME! + }); + expect(result.resourceType === 'Consent').toBe(true); + }); + + it('update method should return OperationOutcome resource', async () => { + const result = await consent.update({ + patientId: '', + action: 'OPTOUT', + agent: '' + }); + expect(result.resourceType === 'OperationOutcome').toBe(true); + }); +}); diff --git a/src/consent.ts b/src/consent.ts new file mode 100644 index 0000000..0d7f5a7 --- /dev/null +++ b/src/consent.ts @@ -0,0 +1,98 @@ +import IHS from './ihs.js'; + +let instance: Consent | undefined; + +export class Consent { + constructor(private readonly ihs: IHS) {} + + /** + * Fungsi dari API ini adalah untuk mendapatkan data terkait resource Consent yang + * tersedia di ekosistem SatuSehat. Jika status 2xx return `Consent` dan selain + * itu return `OperationOutcome` termasuk status 5xx. + * + * @param patientId IHS patient id + * @returns FHIR resource `Consent` or `OperationOutcome`. + */ + async get(patientId: string): Promise { + try { + const response = await this.ihs.request({ + type: 'consent', + path: '/Consent', + searchParams: [['patient_id', patientId]] + }); + + if (response.status >= 500) { + throw new Error(await response.text()); + } + return await response.json(); + } catch (error) { + return this.exception(error); + } + } + + /** + * Fungsi dari API ini adalah untuk melakukan perubahan data terkait resource Consent + * ke dalam ekosistem SatuSehat, yang sebelumnya sudah ditambahkan dan tersedia di + * dalam ekosistem SatuSehat. Jika status 2xx return `Consent` dan selain itu return + * `OperationOutcome` termasuk status 5xx. + * + * @returns FHIR resource `Consent` or `OperationOutcome`. + */ + async update(data: { + /** IHS patient id yang akan dilakukan proses persetujuan */ + patientId: string; + + /** + * Aksi persetujuan yang akan dilakukan. Isi dengan `OPTIN` bila akses disetujui, + * sedangkan bila ditolak isi dengan `OPTOUT`. Persetujuan yang dimaksud adalah + * bersedia dan menyetujui data rekam medis milik pasien diakses dari Fasilitas + * Pelayanan Kesehatan lainnya melalui Platform SatuSehat untuk kepentingan + * pelayanan kesehatan dan/atau rujukan. **Tidak berarti** pengiriman data rekam + * medis tidak dilakukan jika pasien `OPTOUT`. + */ + action: 'OPTIN' | 'OPTOUT'; + + /** + * Nama agen atau petugas yang ingin meminta persetujuan. + */ + agent: string; + }) { + try { + const { patientId: patient_id, ...restData } = data; + const response = await this.ihs.request({ + body: JSON.stringify({ patient_id, ...restData }), + headers: { 'Content-Type': 'application/json' }, + type: 'consent', + path: '/Consent', + method: 'POST' + }); + + if (response.status >= 500) { + throw new Error(await response.text()); + } + return await response.json(); + } catch (error) { + return this.exception(error); + } + } + + private exception(error: unknown): fhir4.OperationOutcome { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const text = (error as any)?.message || 'unknown error'; + return { + resourceType: 'OperationOutcome', + issue: [ + { + code: 'exception', + severity: 'error', + details: { text } + } + ] + }; + } +} + +export function getConsentSingleton(...params: ConstructorParameters): Consent { + if (!instance) instance = new Consent(...params); + return instance; +} diff --git a/src/ihs.spec.ts b/src/ihs.spec.ts new file mode 100644 index 0000000..528f5b3 --- /dev/null +++ b/src/ihs.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import IHS, { AuthManager, IHSConfig } from './ihs.js'; + +describe('ihs', () => { + it('instance should be object and valid', () => { + const ihs = new IHS(); + expect(ihs).toBeTypeOf('object'); + expect(ihs).toBeInstanceOf(IHS); + }); + + it('config should be valid from env file', async () => { + const ihs = new IHS(); + const config = await ihs.getConfig(); + expect(config).toBeTypeOf('object'); + expect(config.clientSecret).toBeDefined(); + expect(config.clientSecret).toBeTypeOf('string'); + expect(config.secretKey).toBeDefined(); + expect(config.secretKey).toBeTypeOf('string'); + expect(config.mode).toBe('development'); + expect(config.kycPemFile).toBe('publickey.dev.pem'); + }); + + it('config should be valid from async config', async () => { + const userConfig: IHSConfig = { + clientSecret: 'th3-53cREt', + secretKey: 'th3_keY', + kycPemFile: 'server-key.pem', + mode: 'development' + }; + + const ihs = new IHS(async () => { + return new Promise((resolve) => { + setTimeout(() => resolve({ ...userConfig }), 100); + }); + }); + + const config = await ihs.getConfig(); + expect(config).toEqual(userConfig); + }); + + it('config kycPemFile should be valid between development and production', async () => { + const ihsDev = new IHS(); + const devConfig = await ihsDev.getConfig(); + expect(devConfig.mode).toBe('development'); + expect(devConfig.kycPemFile).toBe('publickey.dev.pem'); + + const ihsProd = new IHS({ mode: 'production' }); + const prodConfig = await ihsProd.getConfig(); + expect(prodConfig.mode).toBe('production'); + expect(prodConfig.kycPemFile).toBe('publickey.pem'); + }); + + it('cached auth token should not be expired in expiration period', async () => { + const ihs = new IHS(); + const authDetail = await ihs.auth(); + expect(authDetail['expires_in']).toBeDefined(); + expect(authDetail['expires_in']).toBeTypeOf('string'); + expect(+authDetail['expires_in']).toBeTypeOf('number'); + + const delay = 1.5; //seconds + const expiresIn = +authDetail['expires_in']; // 3599 seconds as this test written + const anticipation = 300 + delay; // seconds + const dateProvider = () => { + const current = new Date(); + return new Date(current.getTime() + (expiresIn - anticipation) * 1000); + }; + + const authManager = new AuthManager(dateProvider); + expect(authManager.isTokenExpired).toBe(true); + + authManager.authDetail = authDetail; + expect(authManager.isTokenExpired).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, delay * 1000)); + expect(authManager.isTokenExpired).toBe(true); + }); + + it('get patient resource should be return ok', async () => { + const ihs = new IHS(); + const response = await ihs.fhir(`/Patient/${process.env.TEST_PATIENT_ID}`); + expect(response.ok).toBe(true); + + const patient: fhir4.Patient = await response.json(); + expect(patient.resourceType === 'Patient').toBe(true); + }); +}); diff --git a/src/ihs.ts b/src/ihs.ts new file mode 100644 index 0000000..9f68e33 --- /dev/null +++ b/src/ihs.ts @@ -0,0 +1,217 @@ +import { getConsentSingleton } from './consent.js'; +import { getKycSingleton } from './kyc.js'; + +type Mode = 'development' | 'production'; +type API = 'auth' | 'fhir' | 'consent' | 'kyc'; +type BaseURL = Record>; + +export interface IHSConfig { + /** + * Client secret dari Akses Kode API di Platform SatuSehat + * + * @default process.env.IHS_CLIENT_SECRET + */ + clientSecret: string; + + /** + * Secret key dari Akses Kode API di Platform SatuSehat + * + * @default process.env.IHS_SECRET_KEY + */ + secretKey: string; + + /** + * Mode environment API antara `development` ata `production` + * + * @default process.env.NODE_ENV || 'development' + */ + mode: Mode; + + /** + * Path atau lokasi public key KYC dari SatuSehat. Dapat + * menggunakan absolute atau relative path. Secara default + * akan membaca nilai environment variable IHS_KYC_PEM_FILE + * atau `publickey.dev.pem` pada mode `development` dan + * `publickey.pem` pada mode `production` + * + * @default process.env.IHS_KYC_PEM_FILE + */ + kycPemFile: string; +} + +type UserConfig = Partial | (() => PromiseLike>); + +type RequestConfig = { + type: Exclude; + path: `/${string}`; + searchParams?: URLSearchParams | [string, string][]; +} & RequestInit; + +const defaultBaseUrls: BaseURL = { + development: { + auth: `https://api-satusehat-dev.dto.kemkes.go.id/oauth2/v1`, + fhir: `https://api-satusehat-dev.dto.kemkes.go.id/fhir-r4/v1`, + consent: `https://api-satusehat-dev.dto.kemkes.go.id/consent/v1`, + kyc: `https://api-satusehat-dev.dto.kemkes.go.id/kyc/v1` + }, + production: { + auth: `https://api-satusehat.kemkes.go.id/oauth2/v1`, + fhir: `https://api-satusehat.kemkes.go.id/fhir-r4/v1`, + consent: `https://api-satusehat.kemkes.go.id/consent/v1`, + kyc: `https://api-satusehat.kemkes.go.id/kyc/v1` + } +} as const; + +export default class IHS { + private config: Readonly | undefined; + private readonly authManager = new AuthManager(); + + constructor(private readonly userConfig?: UserConfig) {} + + private async applyUserConfig(): Promise { + const defaultConfig: Readonly = { + mode: process.env['NODE_ENV'] === 'production' ? 'production' : 'development', + clientSecret: process.env['IHS_CLIENT_SECRET'] || '', + secretKey: process.env['IHS_SECRET_KEY'] || '', + kycPemFile: process.env['IHS_KYC_PEM_FILE'] || '' + }; + + const resolveUserConfig = + typeof this.userConfig === 'function' ? await this.userConfig() : this.userConfig; + + const mergedConfig = { ...defaultConfig, ...resolveUserConfig }; + if (!mergedConfig.kycPemFile) { + mergedConfig.kycPemFile = + mergedConfig.mode === 'development' ? 'publickey.dev.pem' : 'publickey.pem'; + } + this.config = mergedConfig; + } + + async getConfig(): Promise { + if (!this.config) await this.applyUserConfig(); + return this.config!; + } + + async invalidateConfig(): Promise { + this.config = undefined; + await this.applyUserConfig(); + } + + /** + * Request ke API `consent`, `fhir`, dan `kyc` yang dapat diatur + * pada property `type` pada parameter `config`. Autentikasi sudah + * ditangani secara otomatis pada method ini. + */ + async request(config: RequestConfig): Promise { + const { mode } = await this.getConfig(); + const { type, path, searchParams, ...init } = config; + const url = new URL(defaultBaseUrls[mode][type] + path); + url.search = searchParams ? new URLSearchParams(searchParams).toString() : url.search; + const auth = await this.auth(); + init.headers = { + Authorization: `Bearer ${auth['access_token']}`, + ...init.headers + }; + return fetch(url, init); + } + + /** + * Autentikasi menggunakan `clientSecret` dan `secretKey` dengan kembalian + * berupa detail autentikasi termasuk `access_token` yang digunakan untuk + * request ke API `consent`, `fhir`, dan `kyc`. + * + * IHS Note: Rate limit is 1 request per minute after a failed attempt. + */ + async auth(): Promise { + if (!this.authManager.isTokenExpired) { + return this.authManager.authDetail!; + } + + const { clientSecret, secretKey, mode } = await this.getConfig(); + if (!clientSecret || !secretKey) { + const message = `Missing credentials. The "clientSecret" and "secretKey" config are required.`; + throw new Error(message); + } + + const url = defaultBaseUrls[mode]['auth'] + '/accesstoken?grant_type=client_credentials'; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams([ + ['client_id', clientSecret], + ['client_secret', secretKey] + ]) + }); + + if (!response.ok) { + const messages = await response.text(); + throw new Error('Authentication failed. ' + messages); + } + this.authManager.authDetail = await response.json(); + return this.authManager.authDetail!; + } + + async fhir( + path: `/${Exclude['resourceType']}${string}` | `/`, + init?: Exclude + ): Promise { + return this.request({ ...init, type: 'fhir', path }); + } + + get consent() { + const instance = getConsentSingleton(this); + return instance; + } + + get kyc() { + const instance = getKycSingleton(this); + return instance; + } +} + +/** @internal Don't use it. Only for internal purposes. */ +export class AuthManager { + private readonly ANTICIPATION = 300; // seconds + + authDetail: AuthDetail | undefined; + + constructor(private readonly currentTimeProvider?: () => Date) {} + + get isTokenExpired(): boolean { + if (!this.authDetail) return true; + const issuedAt = parseInt(this.authDetail.issued_at, 10); + const expiresIn = parseInt(this.authDetail.expires_in, 10); + + // Calculate the expiration time in milliseconds + const expirationTime = issuedAt + expiresIn * 1000; + + // Calculate the anticipation time in milliseconds + const anticipationTime = this.ANTICIPATION * 1000; + + // Calculate the time when the token is considered about to expire + const aboutToExpireTime = expirationTime - anticipationTime; + + // Compare with the current time + const currentTime = this.currentTimeProvider?.()?.getTime() ?? Date.now(); + return aboutToExpireTime <= currentTime; + } +} + +export interface AuthDetail { + refresh_token_expires_in: string; + api_product_list: string; + api_product_list_json: string[]; + organization_name: string; + 'developer.email': string; + token_type: string; + /** elapsed time. eg: 1698379451736 */ + issued_at: string; + client_id: string; + access_token: string; + application_name: string; + scope: string; + /** in seconds */ + expires_in: string; + refresh_count: string; + status: string; +} diff --git a/src/index.spec.ts b/src/index.spec.ts deleted file mode 100644 index 8e9a463..0000000 --- a/src/index.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { MODULE_NAME } from './index.js'; - -describe('index', () => { - it('module name valid', () => { - expect(MODULE_NAME).toBe('ihs'); - }); -}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3b7d421..0000000 --- a/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const MODULE_NAME = 'ihs'; diff --git a/src/kyc.spec.ts b/src/kyc.spec.ts new file mode 100644 index 0000000..a943f52 --- /dev/null +++ b/src/kyc.spec.ts @@ -0,0 +1,55 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import IHS from './ihs.js'; +import { KYC, KycValidationUrlData, KycVerificationCodeData, getKycSingleton } from './kyc.js'; + +let kyc: KYC; + +beforeAll(() => { + const ihs = new IHS(); + kyc = getKycSingleton(ihs); +}); + +describe('kyc', () => { + it('instance should be object and valid KYC instance', async () => { + expect(kyc).toBeTypeOf('object'); + expect(kyc).toBeInstanceOf(KYC); + }); + + it('generate validation url should return data with agent, token, and url', async () => { + const agent = { + name: process.env.TEST_AGENT_NAME!, + nik: process.env.TEST_AGENT_NIK! + }; + const result = await kyc.generateValidationUrl(agent); + const data = result.data as KycValidationUrlData; + expect(data.agent_name).toBe(agent.name); + expect(data.agent_nik).toBe(agent.nik); + expect(data.token).toBeTypeOf('string'); + expect(/^(http|https):\/\/[^ "]+$/.test(data.url)).toBe(true); + }); + + it('generate validation url should return data with error property', async () => { + const result = await kyc.generateValidationUrl({ name: '', nik: '' }); + expect('error' in result.data).toBe(true); + }); + + it('generate verification code should return data with patient and challenge code', async () => { + const patient = { + name: process.env.TEST_PATIENT_NAME!, + nik: process.env.TEST_PATIENT_NIK! + }; + const result = await kyc.generateVerificationCode(patient); + const data = result.data as KycVerificationCodeData; + expect(data.nik).toBe(patient.nik); + expect(data.name).toBe(patient.name); + expect(data.ihs_number).toBeTypeOf('string'); + expect(data.challenge_code).toBeTypeOf('string'); + expect(data.created_timestamp).toBeTypeOf('string'); + expect(data.expired_timestamp).toBeTypeOf('string'); + }); + + it('generate verification code should return data with error property', async () => { + const result = await kyc.generateVerificationCode({ name: '', nik: '' }); + expect('error' in result.data).toBe(true); + }); +}); diff --git a/src/kyc.ts b/src/kyc.ts new file mode 100644 index 0000000..220a38b --- /dev/null +++ b/src/kyc.ts @@ -0,0 +1,274 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import IHS from './ihs.js'; + +let instance: KYC | undefined; + +/** + * Verifikasi Profil (KYC) merupakan proses yang dilakukan untuk mem-verifikasi + * identitas pengguna SatuSehat Mobile dengan mengumpulkan informasi dan data + * tentang pengguna SatuSehat Mobile + */ +export class KYC { + private readonly IV_LENGTH = 12; + private readonly TAG_LENGTH = 16; + private readonly KEY_SIZE = 256; + private readonly ENCRYPTED_TAG = { + BEGIN: `-----BEGIN ENCRYPTED MESSAGE-----`, + END: `-----END ENCRYPTED MESSAGE-----` + }; + + constructor(private readonly ihs: IHS) {} + + /** + * Fungsi dari API ini adalah untuk melakukan proses Generate URL Validasi + * di mana URL digunakan untuk melakukan verifikasi akun SatuSehat melalui + * aplikasi SatuSehat Mobile (SSM) + */ + async generateValidationUrl(agent: { + name: string; + nik: string; + }): Promise> { + try { + // 1) Generate RSA Key Pairs + const [publicKey, privateKey] = await this.generateRsaKeyPairs(); + const payload = await this.encrypt( + JSON.stringify({ + agent_name: agent.name, + agent_nik: agent.nik, + public_key: publicKey + }) + ); + const response = await this.ihs.request({ + headers: { 'Content-Type': 'text/plain' }, + path: '/generate-url', + body: payload, + method: 'POST', + type: 'kyc' + }); + const cipher = await response.text(); + if (response.ok && cipher.startsWith(this.ENCRYPTED_TAG.BEGIN)) { + const plain = await this.decrypt(cipher, privateKey); + return JSON.parse(plain); + } + return JSON.parse(cipher); + } catch (error) { + return this.exception({ error }); + } + } + + /** + * Fungsi dari API ini adalah untuk melakukan proses Generate Kode Verifikasi + * di mana nilai tersebut akan muncul di SatuSehat Mobile (SSM) dan digunakan + * oleh pasien untuk proses validasi + */ + async generateVerificationCode(patient: { + nik: string; + name: string; + }): Promise> { + try { + const payload = JSON.stringify({ + metadata: { method: 'request_per_nik' }, + data: patient + }); + const response = await this.ihs.request({ + headers: { 'Content-Type': 'text/plain' }, + path: '/challenge-code', + body: payload, + method: 'POST', + type: 'kyc' + }); + return response.json(); + } catch (error) { + return this.exception({ error }); + } + } + + private exception(data: { + code?: string; + message?: string; + error: unknown; + }): KycResponse<{ error: string }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const message = (data.error as any)?.message || JSON.stringify(data.error || 'unknown error'); + return { + metadata: { + code: data.code || '500', + message: data.message || 'Internal server error' + }, + data: { error: message } + }; + } + + private async generateRsaKeyPairs(): Promise<[string, string]> { + return new Promise((resolve, reject) => { + crypto.generateKeyPair( + 'rsa', + { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }, + (err, publicKey, privateKey) => { + if (err) reject(err); + resolve([publicKey, privateKey]); + } + ); + }); + } + + private async encrypt(plain: string): Promise { + // 2) Generate AES Symmetric Key + const aesKey = crypto.randomBytes(this.KEY_SIZE / 8); + + // 3) Encrypt message with AES Symmetric Key + const iv = crypto.randomBytes(this.IV_LENGTH); + const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv); + const encryptedMessage = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + // 4) Encrypt AES Symmetric Key with RSA server's Public Key + const ihsPublicKey = await this.getIhsPublicKey(); + const encryptedAesKey = crypto.publicEncrypt( + { + key: ihsPublicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + aesKey + ); + + // 5) Concat encrypted AES Symmetric Key and encrypted message + const merge = Buffer.concat([encryptedAesKey, iv, encryptedMessage, tag]); + return `${this.ENCRYPTED_TAG.BEGIN}\r\n${merge.toString('base64')}\n${this.ENCRYPTED_TAG.END}`; + } + + private async decrypt(text: string, privateKey: string): Promise { + // 6) Encrypted server's AES Symmetric Key + encrypted message + const content = text + .replace(this.ENCRYPTED_TAG.BEGIN, '') + .replace(this.ENCRYPTED_TAG.END, '') + .replace(/\s+/g, ''); + + const contentBuffer = Buffer.from(content, 'base64'); + const aesKey = contentBuffer.subarray(0, this.KEY_SIZE); + const message = contentBuffer.subarray(this.KEY_SIZE); + + // 7) Decrypt server's AES Symmetric Key with client's RSA Private Key + const decryptedAesKey = crypto.privateDecrypt( + { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + aesKey + ); + + const iv = message.subarray(0, this.IV_LENGTH); + const tag = message.subarray(-this.TAG_LENGTH); + const cipher = message.subarray(this.IV_LENGTH, -this.TAG_LENGTH); + + // 8) Decrypt message with decrypted server's AES Symmetric Key + const decipher = crypto.createDecipheriv('aes-256-gcm', decryptedAesKey, iv); + decipher.setAuthTag(tag); + + const decryptedMessage = Buffer.concat([ + decipher.update(cipher.toString('binary'), 'binary'), + decipher.final() + ]); + + return decryptedMessage.toString('utf8'); + } + + /** + * IHS public key atau Server's public key adalah public key + * yang diberikan oleh platform SatuSehat + */ + private async getIhsPublicKey(): Promise { + const { kycPemFile } = await this.ihs.getConfig(); + const resolvePath = path.isAbsolute(kycPemFile) + ? kycPemFile + : path.resolve(process.cwd(), kycPemFile); + return await fs.readFile(resolvePath, 'utf8'); + } +} + +export function getKycSingleton(...params: ConstructorParameters): KYC { + if (!instance) instance = new KYC(...params); + return instance; +} + +export interface KycResponse { + metadata: { + /** + * HTTP code + */ + code: string; + + /** + * Pesan sesuai HTTP code + */ + message: string; + }; + data: T; +} + +export interface KycValidationUrlData { + /** + * Informasi nama petugas/operator Fasilitas Pelayanan Kesehatan + * (Fasyankes) yang akan melakukan validasi + */ + agent_name: string; + + /** + * Informasi nomor identitas petugas/operator Fasilitas Pelayanan + * Kesehatan (Fasyankes) yang akan melakukan validasi + */ + agent_nik: string; + + /** + * Nilai token yang terdapat pada URL sesuai nilai property + * `data.url` yang terdapat pada response ini + */ + token: string; + + /** + * URL lengkap beserta token-nya yang digunakan untuk melakukan validasi + */ + url: string; +} + +export interface KycVerificationCodeData { + /** + * Informasi nomor identitas pasien yang akan di-verifikasi oleh + * petugas/operator Fasilitas Pelayanan Kesehatan (Fasyankes) + */ + nik: string; + + /** + * Informasi nama pasien yang akan di-verifikasi oleh petugas/operator + * Fasilitas Pelayanan Kesehatan (Fasyankes). + */ + name: string; + + /** + * Nomor id SatuSehat pasien + */ + ihs_number: string; + + /** + * Nilai kode verifikasi pasien untuk proses validasi + */ + challenge_code: string; + + /** + * Informasi waktu kode verifikasi dibuat + */ + created_timestamp: string; + + /** + * Informasi kedaluwarsa kode verifikasi + */ + expired_timestamp: string; +} diff --git a/tsconfig.json b/tsconfig.json index 452b031..0a359d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "lib": ["esnext", "DOM"], "outDir": "dist", "rootDirs": ["."], - "declaration": true, + "declaration": false, "stripInternal": true }, "include": [ @@ -18,6 +18,8 @@ "src/**/*.ts" ], "exclude": [ - "node_modules/**" // + "node_modules/**", // + "**/*.test.ts", + "**/*.spec.ts" ] } diff --git a/words.txt b/words.txt index a7c6b6b..b7e120f 100644 --- a/words.txt +++ b/words.txt @@ -1,4 +1,19 @@ +accesstoken +algoritma +Autentikasi +dekripsi +diakses +enkripsi fasyankes fhir +Inisialisasi kemkes +kriptografi +OAEP +OPTIN +OPTOUT +PKCS +publickey +Silampari simrs +Sriwijaya