Setup & Secret Handling
Business view
Before a clinic can do anything with DHS, two things have to be true:
- The clinic's DHS credential is stored in Dentolize. DHS issues each provider a
client secret — a long password that identifies the clinic to the national exchange. It is the keys to the kingdom: anyone holding it can submit claims in the clinic's name.
- Each branch knows its NPHIES provider code. NPHIES identifies the place of care,
not the company. A clinic with a Riyadh site and a Jeddah site has two codes. Every eligibility check and every approval is filed under one specific branch's code.
Both are handled by a three-step wizard at Settings → Integrations → DHS Integration. Only users with the DHS Integration view/edit permission see the tab at all.
The three steps
Step 1 — Add Client Secret. The clinic pastes the secret and presses Save & Test Connection. Dentolize does not simply store it: it first tries to log in to DHS with it. If DHS rejects it, nothing is saved and the clinic is told so. If DHS accepts it, the secret is encrypted and stored, and the wizard moves on.
Once a secret exists, the screen changes: the field shows a masked hint (**** plus the last four characters), the button becomes Test Connection when the field is left empty, and typing a new secret turns the action into a rotation — the old secret is only replaced after the new one has been proven to work.
Step 2 — Load Branches. A table of the clinic's branches with an editable NPHIES code column. Submit Changes only lights up when something has actually changed, and if you undo an edit the button dims again. Continue is always available so you can move on without changing anything.
Step 3 — Confirm & Save. A confirmation panel with two ways back: to the secret (to rotate it) or to the branch mappings (to edit them).
Steps 2 and 3 are locked until a secret exists. Step 1 is always reachable.
What the clinic should know about the secret
- Dentolize never shows the secret back. Only the
****####mask is ever returned. - Rotation is a separate, deliberate action — it cannot happen by accident.
- If the clinic loses the secret, DHS reissues it; Dentolize cannot recover it.
Technical view
Storing the secret
saveDHSIntegration (packages/server/src/resolvers/mutations/DHS/saveDHSIntegration.js:14-90) is validate-then-persist:
- Session company is required, thrown outside the try block so it surfaces as a real
GraphQL error rather than a success:false envelope (:17-19).
POST ${DHS_AUTH_URL}/api/Loginwith{ clientSecret }(:32-36). The response must
satisfy data && data.succeeded && data.data (:45-47).
- Only then
encrypt(clientSecret)andprisma.dHSIntegration.upsertkeyed on
companyId (:49-65).
- Returns
dhsRecordwithhasSecretandclientSecretMask = '****' + clientSecret.slice(-4)
(:67-76).
The raw Prisma row is spread into the response, ciphertext buffers included — but the GraphQL DHSIntegration type (packages/server/src/types.graphql:5468-5476) only declares id, companyId, createdAt, updatedAt, hasSecret, clientSecretMask, secretUpdatedAt, so the bytes are dropped at serialization and never leave the process.
Encryption
packages/server/src/utils/encryption.js — 28 lines, deliberately minimal:
| Property | Value | Line |
|---|---|---|
| Algorithm | aes-256-gcm | :3 |
| Key source | ENCRYPTION_MASTER_KEY, hex-decoded | :10 |
| Key derivation | None — the env var is the key | :10 |
| Key length check | Exactly 32 bytes, else throw | :12-14 |
| IV | Fresh 12 random bytes per encryption | :16-22 |
| Storage | Three separate columns: clientSecretCiphertext, clientSecretIv, clientSecretAuthTag | schema.prisma:5544-5546 |
There is no packed envelope string and no key-version field. Rotating ENCRYPTION_MASTER_KEY invalidates every existing DHSIntegration row — this is documented in the source comment (:4-5) and in .env.example:47-51, and there is no re-encryption job on this branch.
A wrong or rotated master key makes decrypt throw at final() (:24-28). That surfaces through getDHSClientSecret as success:false rather than hasSecret:true — an operator seeing "no secret" after a key change is seeing a decryption failure, not a missing row.
Reading the mask
getDHSClientSecret (packages/server/src/resolvers/queries/DHS/getDHSClientSecret.js:12-58) fully decrypts the secret in memory purely to build the four-character mask (:35-43). The plaintext never leaves the resolver, but it does exist in process memory on every settings-page load.
Rotation
rotateDHSClientSecret(newSecret: String!) (packages/server/src/resolvers/mutations/DHS/rotateDHSClientSecret.js:8-54) takes a bare scalar argument, not an input object. It validates the new secret against /api/Login first and refuses with "New secret failed DHS validation. Existing secret has not been changed." (:22-27). Note its check is !authResponse.data?.succeeded — weaker than saveDHSIntegration's, which also requires data.data.
It uses update, not upsert, so an integration row must already exist; Prisma P2025 is mapped to "No DHS integration found for this company" (:45-50). Every other error collapses to the opaque "Rotation failed. Please try again." (:52) — deliberately not leaking upstream detail, in contrast to saveDHSIntegration.
Also note: on success it returns { success: true } with no message (:41-43).
Testing the connection
testDHSConnection (packages/server/src/resolvers/mutations/DHS/testDHSConnection.js:9-55) is read-only: decrypt, POST /api/Login, report. When no row or no ciphertext exists it short-circuits to "No DHS credentials found to test" (:21-26).
Branch NPHIES codes
updateBranchesNphiesCodes (packages/server/src/resolvers/mutations/DHS/updateBranchesNphiesCodes.js:12-81):
- Runs inside
prisma.$transaction(:27-57). - Tenant guard is a pre-flight count: it loads the requested branch ids filtered by
companyId, and if fewer come back than were asked for it throws Branches not found or unauthorized: <ids> (:28-40). The individual update calls then key on id alone.
nphiesCodeis nullable, so passingnullclears a mapping.- After the transaction commits it calls
branchCache.resetDetailsper branch (:59). A
cache-reset failure lands in the catch and reports failure even though the database write already committed.
dhsBranches (packages/server/src/resolvers/queries/DHS/dhsBranches.js:10-50) returns all company branches including unmapped ones, so the wizard table can show blanks to fill.
Front end
packages/clinic-web-canary/src/features/DHSIntegrationSettings/:
useDHSIntegrationSettings.ts:74-125—handleTestConnectionis a three-way branch:
rotate (secret typed + hasSecret), save (secret typed + no hasSecret), or test (field empty). All three success paths advance to step 1.
useDHSIntegrationSettings.ts:127-142—handleBranchCodeChangeis diff-aware: typing
a value back to its original removes it from the modified set, so Submit Changes re-disables when you undo.
DHSIntegrationSettings.tsx:54-58— steps 1 and 2 are unreachable untilhasSecret.useDHSIntegrationSettings.ts:55-60—hasSecretis only ever set totrue, never
reset to false.
useDHSIntegrationSettings.ts:45— theDHS_AUTHENTICATIONmutation is instantiated
but never invoked; its authenticating flag is permanently false.
Attachment URL hardening
Not part of setup, but part of the same security pass. packages/server/src/utils/validateAttachmentUrl.js guards the attachment fetch in dhsApprovalSubmission:
| Rule | Line |
|---|---|
Allow-list is a single derived host, ${S3_BUCKET_AWS}.s3.${S3_REGION_AWS}.amazonaws.com | :5-13 |
| Throws at import time if either env var is unset | :9-11 |
Protocol must be exactly https: | :34-36 |
Host match uses .host (includes port), so …amazonaws.com:8443 is rejected | :38-40 |
Resolves A and AAAA records and rejects 127., 10., 172.16–31., 192.168., 169.254., ::1, fc00:, fe80: | :15-24, :43-53 |
The caller adds timeout: 10_000, maxContentLength: 10 MiB and — critically — maxRedirects: 0, which is what makes the host allow-list sound (packages/server/src/resolvers/mutations/DHS/dhsApprovalSubmission.js:203-209).
Undocumented requirement:S3_BUCKET_AWSandS3_REGION_AWSare not inpackages/server/.env.example, yet without them this module throws at import anddhsApprovalSubmissioncannot load. See Known Gaps.