From 96c918e1283caff8bb8c93bc05379e21d9f6dfa7 Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:04:35 +0700 Subject: [PATCH 1/4] init --- .env.example | 4 ++- src/ihs.spec.ts | 12 +++---- src/ihs.ts | 85 +++++++++++++++++++++++++++---------------------- 3 files changed, 56 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index 18853cc..99c39ed 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +NODE_ENV=sandbox + IHS_ORGANIZATION_ID= IHS_CLIENT_SECRET= IHS_SECRET_KEY= @@ -7,4 +9,4 @@ TEST_AGENT_NIK= TEST_AGENT_NAME= TEST_PATIENT_NIK= TEST_PATIENT_NAME= -TEST_PATIENT_ID= +TEST_PATIENT_ID=P02478375538 diff --git a/src/ihs.spec.ts b/src/ihs.spec.ts index 17a308a..b377774 100644 --- a/src/ihs.spec.ts +++ b/src/ihs.spec.ts @@ -16,8 +16,8 @@ describe('ihs', () => { 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'); + expect(config.mode).toBe('sandbox'); + expect(config.kycPemFile).toBe('publickey.sandbox.pem'); }); it('config should be valid from async config', async () => { @@ -25,7 +25,7 @@ describe('ihs', () => { clientSecret: 'th3-53cREt', secretKey: 'th3_keY', kycPemFile: 'server-key.pem', - mode: 'development' + mode: 'sandbox' }; const ihs = new IHS(async () => { @@ -38,11 +38,11 @@ describe('ihs', () => { expect(config).toEqual(userConfig); }); - it('config kycPemFile should be valid between development and production', async () => { + it('config kycPemFile should be valid between sandbox and production', async () => { const ihsDev = new IHS(); const devConfig = await ihsDev.getConfig(); - expect(devConfig.mode).toBe('development'); - expect(devConfig.kycPemFile).toBe('publickey.dev.pem'); + expect(devConfig.mode).toBe('sandbox'); + expect(devConfig.kycPemFile).toBe('publickey.sandbox.pem'); const ihsProd = new IHS({ mode: 'production' }); const prodConfig = await ihsProd.getConfig(); diff --git a/src/ihs.ts b/src/ihs.ts index 04c40af..dd3f146 100644 --- a/src/ihs.ts +++ b/src/ihs.ts @@ -3,9 +3,9 @@ import { getKFASingleton } from './kfa.js'; import { getKycSingleton } from './kyc.js'; type MaybePromise = T | Promise; -type Mode = 'development' | 'staging' | 'production'; +type Mode = 'sandbox' | 'production'; type API = 'auth' | 'fhir' | 'consent' | 'kyc' | 'kfa' | 'kfa2' | 'kfa3'; -type BaseURL = Record>; +type EndpointURL = Record>; export interface IHSConfig { /** @@ -23,9 +23,9 @@ export interface IHSConfig { secretKey: string; /** - * Mode environment API antara `development`, `staging`, atau `production` + * Mode environment API antara `sandbox` atau `production` * - * @default process.env.NODE_ENV || 'development' + * @default process.env.NODE_ENV || 'sandbox' */ mode: Mode; @@ -33,7 +33,7 @@ export interface IHSConfig { * 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 + * atau `publickey.sandbox.pem` pada mode `sandbox` dan * `publickey.pem` pada mode `production` * * @default process.env.IHS_KYC_PEM_FILE @@ -45,7 +45,7 @@ type UserConfig = Partial | (() => MaybePromise>); type RequestConfig = { type: Exclude; - path: `/${string}`; + path: string; searchParams?: URLSearchParams | Record | [string, string][]; } & RequestInit; @@ -59,35 +59,38 @@ export interface AuthStore { set(detail: AuthDetail): MaybePromise; } -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`, - kfa: `https://api-satusehat-dev.dto.kemkes.go.id/kfa`, - kfa2: `https://api-satusehat-dev.dto.kemkes.go.id/kfa-v2`, - kfa3: `https://api-satusehat-dev.dto.kemkes.go.id/kfa-v3` - }, - staging: { - auth: `https://api-satusehat-stg.dto.kemkes.go.id/oauth2/v1`, - fhir: `https://api-satusehat-stg.dto.kemkes.go.id/fhir-r4/v1`, - consent: `https://api-satusehat-stg.dto.kemkes.go.id/consent/v1`, - kyc: `https://api-satusehat-stg.dto.kemkes.go.id/kyc/v1`, - kfa: `https://api-satusehat-stg.dto.kemkes.go.id/kfa`, - kfa2: `https://api-satusehat-stg.dto.kemkes.go.id/kfa-v2`, - kfa3: `https://api-satusehat-stg.dto.kemkes.go.id/kfa-v3` +const defaultBaseUrls: Record = { + sandbox: 'https://api-satusehat-stg.dto.kemkes.go.id', + production: 'https://api-satusehat.kemkes.go.id' +}; + +const defaultEndpointUrls = Object.entries(defaultBaseUrls).reduce( + (acc, [mode, url]) => { + acc[mode as Mode] = { + auth: `${url}/oauth2/v1`, + fhir: `${url}/fhir-r4/v1`, + consent: `${url}/consent/v1`, + kyc: `${url}/kyc/v1`, + kfa: `${url}/kfa`, + kfa2: `${url}/kfa-v2`, + kfa3: `${url}/kfa-v3` + }; + return acc; }, - 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`, - kfa: `https://api-satusehat.kemkes.go.id/kfa`, - kfa2: `https://api-satusehat.kemkes.go.id/kfa-v2`, - kfa3: `https://api-satusehat.kemkes.go.id/kfa-v3` - } -} as const; + {} +); + +function buildUrl(base: string, path: string) { + // ensure base ends with a slash so it's treated as a directory + const normalizedBase = base.endsWith('/') ? base : base + '/'; + + /** + * URL constructor params rules + * - base must end with / otherwise it's treated as a file + * - path in input must NOT start with / otherwise it resets the path + */ + return new URL(path.replace(/^\/+/, ''), normalizedBase); +} export default class IHS { private config: Readonly | undefined; @@ -97,7 +100,7 @@ export default class IHS { private async applyUserConfig(): Promise { const defaultConfig: Readonly = { - mode: (process.env['NODE_ENV'] as Mode) || 'development', + mode: (process.env['NODE_ENV'] as Mode) || 'sandbox', clientSecret: process.env['IHS_CLIENT_SECRET'] || '', secretKey: process.env['IHS_SECRET_KEY'] || '', kycPemFile: process.env['IHS_KYC_PEM_FILE'] || '' @@ -107,9 +110,15 @@ export default class IHS { typeof this.userConfig === 'function' ? await this.userConfig() : this.userConfig; const mergedConfig = { ...defaultConfig, ...resolveUserConfig }; + + if (!(['sandbox', 'production']).includes(mergedConfig.mode)) { + console.warn(`[ihs]: Invalid mode "${mergedConfig.mode}", falling back to "sandbox".`); + mergedConfig.mode = 'sandbox'; + } + if (!mergedConfig.kycPemFile) { mergedConfig.kycPemFile = - mergedConfig.mode === 'development' ? 'publickey.dev.pem' : 'publickey.pem'; + mergedConfig.mode === 'sandbox' ? 'publickey.sandbox.pem' : 'publickey.pem'; } this.config = mergedConfig; } @@ -140,7 +149,7 @@ export default class IHS { async request(config: RequestConfig): Promise { const { mode } = await this.getConfig(); const { type, path, searchParams, ...init } = config; - const url = new URL(defaultBaseUrls[mode][type] + path); + const url = buildUrl(defaultEndpointUrls[mode][type], path); url.search = searchParams ? new URLSearchParams(searchParams).toString() : url.search; const auth = await this.auth(); init.headers = { @@ -167,7 +176,7 @@ export default class IHS { throw new Error(message); } - const url = defaultBaseUrls[mode]['auth'] + '/accesstoken?grant_type=client_credentials'; + const url = defaultEndpointUrls[mode]['auth'] + '/accesstoken?grant_type=client_credentials'; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, From 73a84a978924264235e6c98fce3eb518a78e59b8 Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Wed, 7 Jan 2026 08:29:57 +0700 Subject: [PATCH 2/4] changeset --- .changeset/hungry-pugs-relax.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hungry-pugs-relax.md diff --git a/.changeset/hungry-pugs-relax.md b/.changeset/hungry-pugs-relax.md new file mode 100644 index 0000000..f023189 --- /dev/null +++ b/.changeset/hungry-pugs-relax.md @@ -0,0 +1,5 @@ +--- +'@ssecd/ihs': patch +--- + +Remove `development` mode From 771b7fe675fe9b7385c440854bed8531157fd41f Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:04:50 +0700 Subject: [PATCH 3/4] doc --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a4b01f0..54f8789 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Setiap method pada API ini memiliki parameter dan nilai kembalian yang di-defini 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. +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.sandbox.pem` pada mode `sandbox` 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). @@ -259,9 +259,10 @@ interface IHSConfig { secretKey: string; /** - * Mode environment API antara `development`, `staging`, atau `production` + * Mode environment API antara `sandbox` atau `production`, + * `sandbox` secara default * - * @default process.env.NODE_ENV || 'development' + * @default process.env.NODE_ENV || 'sandbox' */ mode: Mode; @@ -269,7 +270,7 @@ interface IHSConfig { * 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 + * atau `publickey.sandbox.pem` pada mode `sandbox` dan * `publickey.pem` pada mode `production` * * @default process.env.IHS_KYC_PEM_FILE From 556842353d866f5eecae7ac793852522841636fc Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:08:03 +0700 Subject: [PATCH 4/4] tidy up doc --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 54f8789..8f7ba58 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ Indonesia Health Service API Helpers -- ✅ FHIR API -- ✅ Patient Consent API -- ✅ KYC API - ✅ Automatic authentication and token invalidation - ✅ TypeSafe and Autocomplete-Enabled API +- ✅ FHIR +- ✅ Patient Consent +- ✅ KYC +- ✅ KFA +- 🏗️ MSI +- 🏗️ Wilayah ## Instalasi