From 3d7c87d480e83dc448257c01ee8db1b54e46a9a7 Mon Sep 17 00:00:00 2001 From: Sishir P Date: Tue, 4 Aug 2026 19:46:05 -0400 Subject: [PATCH] feat(rayverify): consented selfie identity verification at clock-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RayVerify's identity layer was marketed as coming soon and had no implementation. This builds the real thing: a caregiver enrolls a reference selfie once, and each clock-in selfie is compared against it through AWS Rekognition, which is already the project's BAA-covered vendor for SES, Bedrock, and PHI document storage. The consent gate is the point, not a formality. A face image and anything derived from it is a biometric identifier: PHI under HIPAA, and separately governed by state biometric statutes (Illinois BIPA, Texas CUBI, Washington and a growing list) that require informed consent BEFORE collection and destruction on withdrawal. BIPA carries a private right of action. So consent is a table, not a checkbox: it stores the exact text agreed to and its version, both capture endpoints refuse to store anything without a live consent row, and withdrawing consent deletes the enrollment and the stored image, reporting failure rather than claiming a deletion that did not happen. The client echoes back the consent version it displayed. A mismatch is refused, because a consent record whose wording we cannot vouch for is not evidence of anything. Captures are kept only when they do NOT match. A matched selfie is biometric data with no remaining purpose, and storing one per visit would build a face archive nobody needs; a mismatch is something an agency may have to review. LIVENESS IS NOT IMPLEMENTED, and nothing here pretends otherwise. Rekognition Face Liveness needs the Amplify FaceLivenessDetector client SDK, which requires a custom native build the managed Expo app does not have. Without it a photograph of a photograph passes, so this verifies WHO is in the frame and says nothing about whether they were physically present. Every result carries livenessChecked: false and the status endpoint reports livenessSupported: false, so no caller can quietly assume otherwise. The unconfigured fallback reports not_configured, never a pass. A verification product that reports success when it verified nothing is worse than one that reports nothing at all. Marketing copy moves identity from "Rolling out" to "Live" and keeps liveness and device trust as rolling out, per the guardrail in docs/rayverify-integration.md §7, with the presence-versus-identity distinction stated rather than blurred. --- .env.example | 19 + package-lock.json | 446 +++++------------- packages/app/package.json | 1 + packages/app/src/app.ts | 2 + .../__tests__/face-match-client.test.ts | 119 +++++ .../app/src/identity/face-match-client.ts | 173 +++++++ .../routes/__tests__/identity-routes.test.ts | 268 +++++++++++ packages/app/src/routes/identity-routes.ts | 318 +++++++++++++ packages/app/src/services/s3-storage.ts | 34 +- packages/core/src/index.ts | 1 + .../2026-08-04-add-identity-verification.ts | 131 +++++ packages/core/src/migrations/runner.ts | 2 + .../src/repositories/identity-repository.ts | 217 +++++++++ .../features/marketing/site/RayVerifyPage.tsx | 19 +- 14 files changed, 1422 insertions(+), 328 deletions(-) create mode 100644 packages/app/src/identity/__tests__/face-match-client.test.ts create mode 100644 packages/app/src/identity/face-match-client.ts create mode 100644 packages/app/src/routes/__tests__/identity-routes.test.ts create mode 100644 packages/app/src/routes/identity-routes.ts create mode 100644 packages/core/src/migrations/2026-08-04-add-identity-verification.ts create mode 100644 packages/core/src/repositories/identity-repository.ts diff --git a/.env.example b/.env.example index 012053c9..59269776 100644 --- a/.env.example +++ b/.env.example @@ -122,3 +122,22 @@ TWILIO_AUTH_TOKEN= TWILIO_FROM_NUMBER= # Set to 1 on staging/preview to disable outbound texts entirely. SMS_DISABLED= + +# --- RayVerify identity verification (AWS Rekognition) --- +# Set to "rekognition" to turn on selfie identity matching at clock-in. Any +# other value (or unset) leaves the no-op provider in place, which reports +# `not_configured` and never a pass. +# +# BEFORE ENABLING THIS, understand what it collects. A face image and anything +# derived from it is a biometric identifier: PHI under HIPAA, and separately +# governed by state biometric-privacy statutes (Illinois BIPA, Texas CUBI, +# Washington and others) that require informed written consent BEFORE +# collection, a published retention and destruction schedule, and no sale or +# disclosure. BIPA carries a private right of action. The app enforces the +# consent gate in code, but the retention schedule and the consent wording are +# yours to own, and you need an AWS BAA covering Rekognition. +# +# Images are stored in DOCUMENTS_S3_BUCKET alongside other PHI documents. +IDENTITY_VERIFICATION_PROVIDER= +# Region for Rekognition. Falls back to AWS_REGION, then us-east-1. +IDENTITY_REKOGNITION_REGION= diff --git a/package-lock.json b/package-lock.json index 9e2dcd52..5160c482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-sdk/client-rekognition": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1103.0.tgz", + "integrity": "sha512-j8DHkyB0JTY0tSjRyV5jCoIYyegcbD9Cs/7emT4+PfJPSKDSQet/L3Q+2jxViRnURnV3Y3ONiVsOR1dSCtl62A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-sesv2": { "version": "3.1088.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.1088.0.tgz", @@ -125,16 +144,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.975.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", - "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.36", + "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.4", - "@smithy/signature-v4": "^5.6.5", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -144,14 +163,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", - "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -160,16 +179,16 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", - "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -178,22 +197,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.3.tgz", - "integrity": "sha512-WpuqYX4gGkx++fCTSWE8+41JzkZVcrI50SH48Ml4CsG1pyuHKyMmpw/FixBHDrmjoQ553PmeCLa/fZIcst+WyA==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/credential-provider-env": "^3.972.59", - "@aws-sdk/credential-provider-http": "^3.972.61", - "@aws-sdk/credential-provider-login": "^3.972.65", - "@aws-sdk/credential-provider-process": "^3.972.59", - "@aws-sdk/credential-provider-sso": "^3.973.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.65", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -202,15 +221,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.65.tgz", - "integrity": "sha512-xr9rgjYEdmC2Tpg2lwt9o+nOEaK9Qpd+dBjzrVCuWWyQfvhO91Ezu0Hh9ts2VUxOZxmS/k5T9msa34e4R1bnrQ==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -219,20 +238,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.69.tgz", - "integrity": "sha512-wbJGGesd0Tl18bmUcbj1xJ+e7CpuRJ6PIpMywLFuUttGy615lua87cJ0EA8pFpY/QgPuUXbnupWBtSPJ9tyZhg==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.59", - "@aws-sdk/credential-provider-http": "^3.972.61", - "@aws-sdk/credential-provider-ini": "^3.973.3", - "@aws-sdk/credential-provider-process": "^3.972.59", - "@aws-sdk/credential-provider-sso": "^3.973.3", - "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -241,14 +260,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.59", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", - "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -257,16 +276,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", - "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", - "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -275,15 +294,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", - "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -292,17 +311,17 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", - "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -311,13 +330,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", - "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.5", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -326,15 +345,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1088.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", - "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.3", - "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -356,9 +375,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", - "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -5624,9 +5643,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.4.tgz", - "integrity": "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -5637,12 +5656,12 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.9.tgz", - "integrity": "sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5664,12 +5683,12 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.6.tgz", - "integrity": "sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw==", + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5678,12 +5697,12 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.6.tgz", - "integrity": "sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg==", + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -5692,12 +5711,12 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.5.tgz", - "integrity": "sha512-MO5VEhwVl0BN7xVoVeNrZfiUFoQtqxUbgl6/RwOTlMMxCSjblG8twSrVTwz3J4w9WZxd2rBfBAUXjH77agspBg==", + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -19872,6 +19891,7 @@ "name": "@rayhealth/app", "dependencies": { "@ai-sdk/amazon-bedrock": "^5.0.31", + "@aws-sdk/client-rekognition": "^3.1103.0", "@aws-sdk/client-sesv2": "^3.1054.0", "@rayhealth/core": "file:../core", "@simplewebauthn/server": "^13.3.2", @@ -20045,182 +20065,6 @@ "node": ">=20.0.0" } }, - "packages/app/node_modules/@aws-sdk/core": { - "version": "3.976.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", - "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.36", - "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.4", - "@smithy/signature-v4": "^5.6.5", - "@smithy/types": "^4.16.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz", - "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz", - "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz", - "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-login": "^3.972.67", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz", - "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz", - "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-ini": "^3.973.5", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz", - "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz", - "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/token-providers": "3.1092.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "packages/app/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz", - "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/app/node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.972.65", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz", @@ -20239,26 +20083,6 @@ "node": ">=20.0.0" } }, - "packages/app/node_modules/@aws-sdk/nested-clients": { - "version": "3.997.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz", - "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/app/node_modules/@aws-sdk/s3-request-presigner": { "version": "3.1092.0", "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1092.0.tgz", @@ -20277,24 +20101,6 @@ "node": ">=20.0.0" } }, - "packages/app/node_modules/@aws-sdk/token-providers": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz", - "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/types": "^4.16.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "packages/app/node_modules/ai": { "version": "7.0.37", "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.37.tgz", diff --git a/packages/app/package.json b/packages/app/package.json index f88afe71..4f572085 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@ai-sdk/amazon-bedrock": "^5.0.31", + "@aws-sdk/client-rekognition": "^3.1103.0", "@aws-sdk/client-sesv2": "^3.1054.0", "@rayhealth/core": "file:../core", "@simplewebauthn/server": "^13.3.2", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index b9f5f137..6ee061f8 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -39,6 +39,7 @@ import pushTokenRoutes from './routes/push-token-routes.js'; import mileageRoutes from './routes/mileage-routes.js'; import availabilityRoutes from './routes/availability-routes.js'; import messageRoutes from './routes/message-routes.js'; +import identityRoutes from './routes/identity-routes.js'; import agencySandataConfigRoutes from './routes/agency-sandata-config-routes.js'; import agencyHhaexchangeConfigRoutes from './routes/agency-hhaexchange-config-routes.js'; import agencyClearinghouseConfigRoutes from './routes/agency-clearinghouse-config-routes.js'; @@ -354,6 +355,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/mileage`, mileageRoutes); app.use(`${prefix}/availability`, availabilityRoutes); app.use(`${prefix}/messages`, messageRoutes); + app.use(`${prefix}/identity`, identityRoutes); app.use(`${prefix}/compliance-engine`, complianceEngineRoutes); app.use(`${prefix}/command-center`, copilotLimiter, commandCenterRoutes); app.use(`${prefix}/documents`, documentRoutes); diff --git a/packages/app/src/identity/__tests__/face-match-client.test.ts b/packages/app/src/identity/__tests__/face-match-client.test.ts new file mode 100644 index 00000000..bbfd479b --- /dev/null +++ b/packages/app/src/identity/__tests__/face-match-client.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FACE_MATCH_THRESHOLD, + createFaceMatchClient, + isIdentityVerificationConfigured, + resetFaceMatchClient, +} from '../face-match-client.js'; + +const OLD_ENV = { ...process.env }; +const IMAGE = Buffer.from('fake-jpeg-bytes'); + +beforeEach(() => { + delete process.env.IDENTITY_VERIFICATION_PROVIDER; + resetFaceMatchClient(); +}); + +afterEach(() => { + process.env = { ...OLD_ENV }; + vi.restoreAllMocks(); + resetFaceMatchClient(); +}); + +describe('provider selection', () => { + it('falls back to a no-op that reports not_configured, never a pass', async () => { + // A verification product that reports success when it verified nothing is + // worse than one that reports nothing at all. + const result = await createFaceMatchClient().compare(IMAGE, IMAGE); + + expect(result.outcome).toBe('not_configured'); + expect(result.similarity).toBeNull(); + expect(isIdentityVerificationConfigured()).toBe(false); + }); + + it('reports configured only when the provider is explicitly selected', () => { + expect(isIdentityVerificationConfigured()).toBe(false); + process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition'; + expect(isIdentityVerificationConfigured()).toBe(true); + }); + + it('never claims a liveness check it did not perform', async () => { + const result = await createFaceMatchClient().compare(IMAGE, IMAGE); + // Face match answers who is in the frame, not whether a person was there. + expect(result.livenessChecked).toBe(false); + }); +}); + +describe('match threshold', () => { + it('is stricter than the AWS default, because a false accept starts a paid shift', () => { + expect(FACE_MATCH_THRESHOLD).toBeGreaterThan(80); + }); +}); + +describe('rekognition result mapping', () => { + /** + * Exercises the mapping by driving the real client against a stubbed AWS + * transport, so the outcome logic is covered without a network call. + */ + async function compareWith(response: Record) { + process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition'; + const rekognition = await import('@aws-sdk/client-rekognition'); + vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockResolvedValue( + response as never, + ); + return createFaceMatchClient().compare(IMAGE, IMAGE); + } + + it('matches above the threshold', async () => { + const result = await compareWith({ FaceMatches: [{ Similarity: 97.4 }] }); + expect(result).toMatchObject({ outcome: 'matched', similarity: 97, provider: 'rekognition' }); + }); + + it('rejects a similar-but-below-threshold face', async () => { + const result = await compareWith({ FaceMatches: [{ Similarity: 88 }] }); + expect(result).toMatchObject({ outcome: 'not_matched', similarity: 88 }); + }); + + it('takes the strongest match when several faces are returned', async () => { + const result = await compareWith({ FaceMatches: [{ Similarity: 41 }, { Similarity: 95 }] }); + expect(result).toMatchObject({ outcome: 'matched', similarity: 95 }); + }); + + it('separates "a different person" from "no face in frame"', async () => { + // A face that simply is not the enrolled person. + const different = await compareWith({ FaceMatches: [], UnmatchedFaces: [{}] }); + expect(different.outcome).toBe('not_matched'); + + vi.restoreAllMocks(); + // Nothing face-like at all: a dark or blurred capture, which should ask + // for a retake rather than accuse anyone. + const empty = await compareWith({ FaceMatches: [], UnmatchedFaces: [] }); + expect(empty.outcome).toBe('no_face'); + expect(empty.similarity).toBeNull(); + }); + + it('treats an unreadable source image as a retake, not a system error', async () => { + process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition'; + const rekognition = await import('@aws-sdk/client-rekognition'); + const err = new Error('no face'); + err.name = 'InvalidParameterException'; + vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockRejectedValue(err as never); + + const result = await createFaceMatchClient().compare(IMAGE, IMAGE); + + expect(result.outcome).toBe('no_face'); + }); + + it('reports an error rather than a pass when the provider fails', async () => { + process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition'; + const rekognition = await import('@aws-sdk/client-rekognition'); + vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockRejectedValue( + new Error('service unavailable') as never, + ); + + const result = await createFaceMatchClient().compare(IMAGE, IMAGE); + + expect(result.outcome).toBe('error'); + expect(result.similarity).toBeNull(); + }); +}); diff --git a/packages/app/src/identity/face-match-client.ts b/packages/app/src/identity/face-match-client.ts new file mode 100644 index 00000000..7bb33380 --- /dev/null +++ b/packages/app/src/identity/face-match-client.ts @@ -0,0 +1,173 @@ +/** + * Face matching for RayVerify identity verification. + * + * Compares a clock-in selfie against the caregiver's enrolled reference face + * and reports how confident the provider is that they are the same person. + * + * Provider selection (first match wins), same shape as email-client.ts: + * 1. AWS Rekognition, when IDENTITY_VERIFICATION_PROVIDER=rekognition. + * AWS is already the project's BAA-covered vendor for SES, Bedrock, and + * PHI document storage, so no new vendor relationship is needed. + * 2. No-op fallback, which reports `not_configured` rather than pretending + * to have verified anybody. + * + * A stub that returns a confident score is the single most dangerous thing + * this module could contain: it would let the product claim verified identity + * while checking nothing. The fallback therefore fails loudly-in-data + * (`not_configured`) and callers must not treat that as a pass. + * + * LIVENESS IS NOT IMPLEMENTED. See `docs/rayverify-integration.md` §7: face + * and liveness must be presented as rolling out, not live, until a real + * provider is wired. AWS Rekognition Face Liveness requires the Amplify + * FaceLivenessDetector client SDK, which needs a custom native build the + * managed Expo app does not have today. Without it, a still photograph of a + * photograph passes face match, so this module verifies WHO is in the frame + * and says nothing about whether they were physically present. The + * `livenessChecked` flag is on the result so no caller can silently assume + * otherwise. + */ + +import { + RekognitionClient, + CompareFacesCommand, + type CompareFacesCommandOutput, +} from '@aws-sdk/client-rekognition'; +import { safeError } from '../security/safe-log.js'; + +/** + * Similarity below which a comparison is not a match, as a percentage. + * + * 90 rather than AWS's 80 default: this gates a caregiver's ability to start a + * paid shift, so a false accept (someone else clocking in) is worse than a + * false reject (a retake). Agencies see rejected checks and can override the + * visit through the existing exception path. + */ +export const FACE_MATCH_THRESHOLD = 90; + +export type FaceMatchOutcome = + | 'matched' + | 'not_matched' + /** No face found in one of the images, e.g. a dark or blurred capture. */ + | 'no_face' + | 'error' + | 'not_configured'; + +export interface FaceMatchResult { + outcome: FaceMatchOutcome; + /** Provider-reported similarity 0..100, null when no comparison happened. */ + similarity: number | null; + provider: string; + /** + * Always false today. Present so a caller can never mistake face match for + * proof of physical presence. See the module note on liveness. + */ + livenessChecked: boolean; +} + +export interface FaceMatchClient { + compare(reference: Buffer, capture: Buffer): Promise; +} + +function createRekognitionClient(): FaceMatchClient { + const region = + process.env.IDENTITY_REKOGNITION_REGION?.trim() || + process.env.AWS_REGION?.trim() || + 'us-east-1'; + const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim(); + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim(); + + const client = new RekognitionClient({ + region, + ...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}), + }); + + return { + async compare(reference: Buffer, capture: Buffer): Promise { + try { + const response: CompareFacesCommandOutput = await client.send( + new CompareFacesCommand({ + // Copied into plain Uint8Arrays: the SDK's Bytes field is typed + // against ArrayBuffer, and Node's Buffer can be backed by a + // SharedArrayBuffer, which the type will not accept. + SourceImage: { Bytes: Uint8Array.from(reference) }, + TargetImage: { Bytes: Uint8Array.from(capture) }, + // Ask below our own threshold so a near-miss comes back with a + // score we can record, rather than as an empty result we cannot + // tell apart from "no face in frame". + SimilarityThreshold: 1, + QualityFilter: 'AUTO', + }), + ); + + const best = (response.FaceMatches ?? []).reduce((max, match) => { + const similarity = match.Similarity ?? 0; + return max == null || similarity > max ? similarity : max; + }, null); + + if (best == null) { + // Rekognition reports unmatched faces separately from "no face at + // all". An empty target set means nothing face-like was found. + const sawAFace = (response.UnmatchedFaces ?? []).length > 0; + return { + outcome: sawAFace ? 'not_matched' : 'no_face', + similarity: sawAFace ? 0 : null, + provider: 'rekognition', + livenessChecked: false, + }; + } + + const rounded = Math.round(best); + return { + outcome: rounded >= FACE_MATCH_THRESHOLD ? 'matched' : 'not_matched', + similarity: rounded, + provider: 'rekognition', + livenessChecked: false, + }; + } catch (err) { + // InvalidParameterException is what Rekognition raises when it cannot + // find a face in the SOURCE image, which is a capture-quality problem + // rather than a system failure, and the caregiver should be asked to + // retake rather than shown an error. + const name = err instanceof Error ? err.name : ''; + if (name === 'InvalidParameterException') { + return { outcome: 'no_face', similarity: null, provider: 'rekognition', livenessChecked: false }; + } + safeError('rekognition compare failed', err); + return { outcome: 'error', similarity: null, provider: 'rekognition', livenessChecked: false }; + } + }, + }; +} + +function createNoopClient(): FaceMatchClient { + return { + async compare(): Promise { + // Deliberately NOT a pass. A verification product that reports success + // when it verified nothing is worse than one that reports nothing. + return { outcome: 'not_configured', similarity: null, provider: 'none', livenessChecked: false }; + }, + }; +} + +export function createFaceMatchClient(): FaceMatchClient { + if (process.env.IDENTITY_VERIFICATION_PROVIDER?.trim() === 'rekognition') { + return createRekognitionClient(); + } + return createNoopClient(); +} + +let cached: FaceMatchClient | null = null; + +export function getFaceMatchClient(): FaceMatchClient { + if (!cached) cached = createFaceMatchClient(); + return cached; +} + +export function resetFaceMatchClient(): void { + cached = null; +} + +/** True when a real provider is wired up, for readiness reporting. */ +export function isIdentityVerificationConfigured(): boolean { + return process.env.IDENTITY_VERIFICATION_PROVIDER?.trim() === 'rekognition'; +} diff --git a/packages/app/src/routes/__tests__/identity-routes.test.ts b/packages/app/src/routes/__tests__/identity-routes.test.ts new file mode 100644 index 00000000..a7b84855 --- /dev/null +++ b/packages/app/src/routes/__tests__/identity-routes.test.ts @@ -0,0 +1,268 @@ +import request from 'supertest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import * as core from '@rayhealth/core'; +import { createApp } from '../../app.js'; +import { CONSENT_VERSION } from '../identity-routes.js'; +import * as s3 from '../../services/s3-storage.js'; +import * as faceMatch from '../../identity/face-match-client.js'; +import { makeToken, setTestJwtSecret } from './test-helpers.js'; + +beforeAll(() => setTestJwtSecret()); +afterEach(() => vi.restoreAllMocks()); + +const agencyId = '00000000-0000-4000-8000-00000000c001'; +const userId = '00000000-0000-4000-8000-00000000c002'; +const caregiverId = '00000000-0000-4000-8000-00000000c003'; +const visitId = '00000000-0000-4000-8000-00000000c004'; + +// A base64 payload long enough to clear the schema's minimum length. +const IMAGE_B64 = Buffer.from('x'.repeat(200)).toString('base64'); + +function mockRepo(overrides: Record = {}) { + const base = { + hasActiveConsent: vi.fn().mockResolvedValue(true), + findActiveConsent: vi.fn().mockResolvedValue(null), + findEnrollment: vi.fn().mockResolvedValue({ referenceKey: 'identity/ref.jpg' }), + grantConsent: vi.fn().mockResolvedValue({ consentVersion: CONSENT_VERSION, grantedAt: 'now' }), + revokeConsent: vi.fn().mockResolvedValue({ referenceKey: 'identity/ref.jpg' }), + upsertEnrollment: vi.fn().mockResolvedValue({ previousKey: null }), + recordVerification: vi.fn().mockResolvedValue(undefined), + markVisitIdentity: vi.fn().mockResolvedValue(true), + ...overrides, + }; + vi.spyOn(core, 'IdentityRepository').mockImplementation( + () => base as unknown as core.IdentityRepository, + ); + return base; +} + +function mockStorage(overrides: Record = {}) { + const base = { + uploadDocument: vi.fn().mockResolvedValue({ uri: 's3://b/k', key: 'k' }), + getObject: vi.fn().mockResolvedValue(Buffer.from('reference')), + deleteObject: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + vi.spyOn(s3, 'S3StorageService').mockImplementation( + () => base as unknown as s3.S3StorageService, + ); + return base; +} + +function mockMatch(outcome: string, similarity: number | null = null) { + const compare = vi + .fn() + .mockResolvedValue({ outcome, similarity, provider: 'rekognition', livenessChecked: false }); + vi.spyOn(faceMatch, 'getFaceMatchClient').mockReturnValue({ compare }); + return compare; +} + +const auth = () => `Bearer ${makeToken('caregiver', agencyId, userId, caregiverId)}`; +const adminAuth = () => `Bearer ${makeToken('admin', agencyId, userId)}`; + +describe('the consent gate', () => { + it('refuses to store an enrollment photo without consent', async () => { + const repo = mockRepo({ hasActiveConsent: vi.fn().mockResolvedValue(false) }); + const storage = mockStorage(); + + const res = await request(createApp()) + .post('/identity/enroll') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('CONSENT_REQUIRED'); + // Nothing biometric may touch storage before consent exists. + expect(storage.uploadDocument).not.toHaveBeenCalled(); + expect(repo.upsertEnrollment).not.toHaveBeenCalled(); + }); + + it('refuses to compare a capture without consent', async () => { + mockRepo({ hasActiveConsent: vi.fn().mockResolvedValue(false) }); + const storage = mockStorage(); + const compare = mockMatch('matched', 99); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + + expect(res.status).toBe(403); + expect(compare).not.toHaveBeenCalled(); + expect(storage.uploadDocument).not.toHaveBeenCalled(); + }); + + it('rejects consent recorded against stale wording', async () => { + // The client echoes the version it displayed; a mismatch means the app + // showed text this server would not record. + const repo = mockRepo(); + + const res = await request(createApp()) + .post('/identity/consent') + .set('Authorization', auth()) + .send({ consentVersion: '1999-01-01.1' }); + + expect(res.status).toBe(409); + expect(repo.grantConsent).not.toHaveBeenCalled(); + }); + + it('records consent verbatim with its version', async () => { + const repo = mockRepo(); + + const res = await request(createApp()) + .post('/identity/consent') + .set('Authorization', auth()) + .send({ consentVersion: CONSENT_VERSION }); + + expect(res.status).toBe(201); + expect(repo.grantConsent).toHaveBeenCalledWith( + expect.objectContaining({ + agencyId, + caregiverId, + consentVersion: CONSENT_VERSION, + consentText: expect.stringContaining('photograph'), + }), + ); + }); +}); + +describe('withdrawing consent destroys the data', () => { + it('deletes the stored reference image, not just the row', async () => { + const repo = mockRepo(); + const storage = mockStorage(); + + const res = await request(createApp()).delete('/identity/consent').set('Authorization', auth()); + + expect(res.status).toBe(204); + expect(repo.revokeConsent).toHaveBeenCalledWith(caregiverId, agencyId); + expect(storage.deleteObject).toHaveBeenCalledWith('identity/ref.jpg'); + }); + + it('does not report success when the image could not be deleted', async () => { + // Telling somebody their biometric data is gone when it is not would be + // the worst possible lie for this feature to tell. + mockRepo(); + mockStorage({ deleteObject: vi.fn().mockRejectedValue(new Error('s3 down')) }); + + const res = await request(createApp()).delete('/identity/consent').set('Authorization', auth()); + + expect(res.status).toBe(500); + }); +}); + +describe('POST /identity/verify', () => { + it('records a match and stamps the visit', async () => { + const repo = mockRepo(); + mockStorage(); + mockMatch('matched', 97); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64, visitId }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ outcome: 'matched', similarity: 97, livenessChecked: false }); + expect(repo.markVisitIdentity).toHaveBeenCalledWith(visitId, agencyId, 'matched', 97); + }); + + it('keeps the capture only when it did not match', async () => { + // A matched selfie is biometric data with no remaining purpose; storing + // one per visit would build a face archive nobody needs. + mockRepo(); + const matchedStorage = mockStorage(); + mockMatch('matched', 99); + await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + expect(matchedStorage.uploadDocument).not.toHaveBeenCalled(); + + vi.restoreAllMocks(); + + mockRepo(); + const mismatchStorage = mockStorage(); + mockMatch('not_matched', 40); + await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + expect(mismatchStorage.uploadDocument).toHaveBeenCalled(); + }); + + it('reports not_enrolled instead of comparing against nothing', async () => { + const repo = mockRepo({ findEnrollment: vi.fn().mockResolvedValue(null) }); + mockStorage(); + const compare = mockMatch('matched', 99); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64 }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('NOT_ENROLLED'); + expect(compare).not.toHaveBeenCalled(); + expect(repo.recordVerification).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'not_enrolled' }), + ); + }); + + it('surfaces an unconfigured provider as its own outcome, never as a pass', async () => { + const repo = mockRepo(); + mockStorage(); + vi.spyOn(faceMatch, 'getFaceMatchClient').mockReturnValue({ + compare: vi.fn().mockResolvedValue({ + outcome: 'not_configured', + similarity: null, + provider: 'none', + livenessChecked: false, + }), + }); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: IMAGE_B64, visitId }); + + expect(res.body.outcome).toBe('not_configured'); + expect(repo.markVisitIdentity).toHaveBeenCalledWith(visitId, agencyId, 'not_configured', null); + }); + + it('rejects an unreadable payload before touching storage', async () => { + mockRepo(); + const storage = mockStorage(); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: 'short' }); + + expect(res.status).toBe(400); + expect(storage.getObject).not.toHaveBeenCalled(); + }); + + it('is caregiver-only', async () => { + mockRepo(); + mockStorage(); + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', adminAuth()) + .send({ imageBase64: IMAGE_B64 }); + expect(res.status).toBe(403); + }); +}); + +describe('GET /identity/status', () => { + it('states plainly that liveness is not supported', async () => { + mockRepo(); + + const res = await request(createApp()).get('/identity/status').set('Authorization', auth()); + + expect(res.status).toBe(200); + // Declared in the API so an integrator cannot mistake face match for proof + // of physical presence. + expect(res.body.livenessSupported).toBe(false); + expect(res.body.consentText).toContain('photograph'); + }); +}); diff --git a/packages/app/src/routes/identity-routes.ts b/packages/app/src/routes/identity-routes.ts new file mode 100644 index 00000000..e286d952 --- /dev/null +++ b/packages/app/src/routes/identity-routes.ts @@ -0,0 +1,318 @@ +/** + * RayVerify identity verification. + * + * A caregiver consents once, enrolls a reference selfie, and each clock-in + * selfie is compared against it. + * + * THE CONSENT GATE IS THE POINT. Face images are biometric identifiers: PHI + * under HIPAA, and separately governed by state biometric statutes (Illinois + * BIPA and friends) that require informed consent BEFORE collection and + * destruction on revocation. Every capture endpoint here refuses to store + * anything without a live consent row, and revoking consent deletes both the + * enrollment row and the stored image. That is enforced in code because a + * policy nobody executes is not a defense. + * + * WHAT THIS DOES NOT DO: liveness. Face match answers "who is in this frame", + * not "was a person physically present". A photograph of a photograph passes. + * Every result carries `livenessChecked: false` so no caller, and no marketing + * page, can quietly assume otherwise. See docs/rayverify-integration.md §7. + */ +import { Router, type Request, type Response } from 'express'; +import type { Knex } from 'knex'; +import { z } from 'zod'; +import { IdentityRepository } from '@rayhealth/core'; +import { requireCapability } from '../middleware/require-capability.js'; +import { safeError } from '../security/safe-log.js'; +import { S3StorageService } from '../services/s3-storage.js'; +import { + getFaceMatchClient, + isIdentityVerificationConfigured, +} from '../identity/face-match-client.js'; + +const router = Router(); + +/** + * The consent text a caregiver agrees to. Stored verbatim on the consent row, + * so a later dispute can show exactly what was presented. Bump the version + * whenever the wording changes; an old consent does not cover new wording. + */ +export const CONSENT_VERSION = '2026-08-04.1'; +export const CONSENT_TEXT = [ + 'I agree that RayHealth may collect and store a photograph of my face, and', + 'compare photographs taken when I clock in against it, to confirm that I am', + 'the person working the visit.', + '', + 'My photographs are stored encrypted, are used only for this purpose, are', + 'never sold or shared for advertising, and are deleted when I withdraw this', + 'agreement or when my account is closed.', + '', + 'I can withdraw this agreement at any time in the RayHealth app, and my', + 'stored photograph will be deleted.', +].join(' '); + +/** Base64 JPEG, capped so a single request cannot be used to push large blobs. */ +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +const imageSchema = z.object({ + imageBase64: z.string().min(100), +}); +const captureSchema = imageSchema.extend({ + visitId: z.string().uuid().optional(), +}); + +function decodeImage(base64: string): Buffer | null { + const cleaned = base64.replace(/^data:image\/\w+;base64,/, ''); + try { + const buffer = Buffer.from(cleaned, 'base64'); + if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES) return null; + return buffer; + } catch { + return null; + } +} + +/** Object keys are namespaced per agency so a retention sweep can scope by prefix. */ +function referenceKey(agencyId: string, caregiverId: string): string { + return `identity/${agencyId}/${caregiverId}/reference.jpg`; +} +function captureKey(agencyId: string, caregiverId: string, stamp: string): string { + return `identity/${agencyId}/${caregiverId}/captures/${stamp}.jpg`; +} + +// GET /identity/status, what this caregiver has consented to and enrolled +router.get('/status', requireCapability('evv.read'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Identity verification applies to caregivers' }); + } + try { + const db = req.app.get('db') as Knex; + const repo = new IdentityRepository(db); + const [consent, enrollment] = await Promise.all([ + repo.findActiveConsent(req.auth.caregiverId, req.auth.agencyId), + repo.findEnrollment(req.auth.caregiverId, req.auth.agencyId), + ]); + res.json({ + configured: isIdentityVerificationConfigured(), + consented: consent !== null, + consentVersion: consent?.consentVersion ?? null, + enrolled: enrollment !== null, + enrolledAt: enrollment?.enrolledAt ?? null, + consentText: CONSENT_TEXT, + currentConsentVersion: CONSENT_VERSION, + // Stated in the API, not just the UI, so an integrator cannot mistake + // face match for proof of physical presence. + livenessSupported: false, + }); + } catch (error) { + safeError('identity status failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /identity/consent, record informed consent +router.post('/consent', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Identity verification applies to caregivers' }); + } + const version = typeof req.body?.consentVersion === 'string' ? req.body.consentVersion : ''; + // The client echoes back the version it displayed. A mismatch means the app + // showed different wording than this server would record, so refuse rather + // than store a consent whose text we cannot vouch for. + if (version !== CONSENT_VERSION) { + return res.status(409).json({ + message: 'Consent wording has changed. Please reopen the screen and read the current text.', + currentConsentVersion: CONSENT_VERSION, + }); + } + try { + const db = req.app.get('db') as Knex; + const consent = await new IdentityRepository(db).grantConsent({ + agencyId: req.auth.agencyId, + caregiverId: req.auth.caregiverId, + consentText: CONSENT_TEXT, + consentVersion: CONSENT_VERSION, + }); + res.status(201).json({ consentVersion: consent.consentVersion, grantedAt: consent.grantedAt }); + } catch (error) { + safeError('identity consent failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// DELETE /identity/consent, withdraw and destroy the enrollment +router.delete('/consent', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Identity verification applies to caregivers' }); + } + try { + const db = req.app.get('db') as Knex; + const { referenceKey: retiredKey } = await new IdentityRepository(db).revokeConsent( + req.auth.caregiverId, + req.auth.agencyId, + ); + // Destruction, not just a flag: biometric statutes require the data to go. + // A storage failure must not leave the caregiver believing it is gone, so + // it is surfaced rather than swallowed. + if (retiredKey) { + try { + await new S3StorageService().deleteObject(retiredKey); + } catch (error) { + safeError('identity reference deletion failed', error); + return res.status(500).json({ + message: + 'Your consent was withdrawn but the stored photo could not be deleted. Please contact your agency.', + }); + } + } + res.status(204).end(); + } catch (error) { + safeError('identity revoke failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /identity/enroll, store the reference selfie +router.post('/enroll', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Identity verification applies to caregivers' }); + } + const parsed = imageSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: 'A photo is required' }); + } + const image = decodeImage(parsed.data.imageBase64); + if (!image) { + return res.status(400).json({ message: 'That photo could not be read. Please retake it.' }); + } + + try { + const db = req.app.get('db') as Knex; + const repo = new IdentityRepository(db); + // THE GATE: nothing biometric is stored without live consent. + if (!(await repo.hasActiveConsent(req.auth.caregiverId, req.auth.agencyId))) { + return res.status(403).json({ message: 'CONSENT_REQUIRED', code: 'CONSENT_REQUIRED' }); + } + + const key = referenceKey(req.auth.agencyId, req.auth.caregiverId); + const storage = new S3StorageService(); + await storage.uploadDocument({ key, body: image, contentType: 'image/jpeg' }); + + const { previousKey } = await repo.upsertEnrollment({ + agencyId: req.auth.agencyId, + caregiverId: req.auth.caregiverId, + referenceKey: key, + }); + // Re-enrolling overwrites the same key, so a differing previous key is the + // only case with an orphan to clean up. + if (previousKey && previousKey !== key) { + try { + await storage.deleteObject(previousKey); + } catch (error) { + safeError('superseded reference deletion failed', error); + } + } + + res.status(201).json({ enrolled: true }); + } catch (error) { + safeError('identity enroll failed', error); + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +// POST /identity/verify, compare a clock-in selfie against the enrollment +router.post('/verify', requireCapability('evv.write'), async (req: Request, res: Response) => { + if (!req.auth.caregiverId) { + return res.status(403).json({ message: 'Identity verification applies to caregivers' }); + } + const parsed = captureSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ message: 'A photo is required' }); + } + const image = decodeImage(parsed.data.imageBase64); + if (!image) { + return res.status(400).json({ message: 'That photo could not be read. Please retake it.' }); + } + + const db = req.app.get('db') as Knex; + const repo = new IdentityRepository(db); + const caregiverId = req.auth.caregiverId; + const agencyId = req.auth.agencyId; + + try { + if (!(await repo.hasActiveConsent(caregiverId, agencyId))) { + return res.status(403).json({ message: 'CONSENT_REQUIRED', code: 'CONSENT_REQUIRED' }); + } + + const enrollment = await repo.findEnrollment(caregiverId, agencyId); + if (!enrollment) { + await repo.recordVerification({ + agencyId, + caregiverId, + visitId: parsed.data.visitId ?? null, + outcome: 'not_enrolled', + provider: 'none', + }); + return res.status(409).json({ outcome: 'not_enrolled', code: 'NOT_ENROLLED' }); + } + + const storage = new S3StorageService(); + const reference = await storage.getObject(enrollment.referenceKey); + const result = await getFaceMatchClient().compare(reference, image); + + // The capture is kept only when it did NOT match. A matched selfie is + // biometric data with no remaining purpose, and storing one per visit + // would build a face archive nobody needs; a mismatch is evidence an + // agency may have to review. + let storedCaptureKey: string | null = null; + if (result.outcome === 'not_matched') { + storedCaptureKey = captureKey(agencyId, caregiverId, new Date().toISOString().replace(/[:.]/g, '-')); + try { + await storage.uploadDocument({ key: storedCaptureKey, body: image, contentType: 'image/jpeg' }); + } catch (error) { + safeError('identity capture storage failed', error); + storedCaptureKey = null; + } + } + + await repo.recordVerification({ + agencyId, + caregiverId, + visitId: parsed.data.visitId ?? null, + captureKey: storedCaptureKey, + outcome: result.outcome === 'not_configured' ? 'not_configured' : result.outcome, + similarity: result.similarity, + provider: result.provider, + }); + + if (parsed.data.visitId) { + await repo.markVisitIdentity( + parsed.data.visitId, + agencyId, + result.outcome === 'not_configured' ? 'not_configured' : result.outcome, + result.similarity, + ); + } + + res.json({ + outcome: result.outcome, + similarity: result.similarity, + // Never inferred by the client: face match is not presence. + livenessChecked: result.livenessChecked, + }); + } catch (error) { + safeError('identity verify failed', error); + try { + await repo.recordVerification({ + agencyId, + caregiverId, + visitId: parsed.data.visitId ?? null, + outcome: 'error', + provider: 'unknown', + }); + } catch { + /* the response below is what matters */ + } + res.status(500).json({ message: 'Internal Server Error' }); + } +}); + +export default router; diff --git a/packages/app/src/services/s3-storage.ts b/packages/app/src/services/s3-storage.ts index ebf41e1e..baaa861c 100644 --- a/packages/app/src/services/s3-storage.ts +++ b/packages/app/src/services/s3-storage.ts @@ -11,7 +11,12 @@ * AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY when set, otherwise the default SDK * credential chain (e.g. an attached IAM role). */ -import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export interface UploadDocumentParams { @@ -82,4 +87,31 @@ export class S3StorageService { expiresIn: params.expiresInSeconds ?? 15 * 60, }); } + + /** + * Read an object's bytes server-side. + * + * Used where the server itself is the consumer (comparing a stored reference + * face against a fresh capture) rather than handing a URL to a browser. PHI + * that only the server needs should never take a trip through a presigned + * URL it does not have to. + */ + async getObject(key: string): Promise { + const response = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: key }), + ); + if (!response.Body) throw new Error(`s3 object has no body: ${key}`); + const bytes = await response.Body.transformToByteArray(); + return Buffer.from(bytes); + } + + /** + * Delete an object. + * + * Exists so a caregiver withdrawing biometric consent results in actual + * destruction rather than an orphaned image and a flag in a table. + */ + async deleteObject(key: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5e5465f..3600636c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,3 +81,4 @@ export * from './repositories/push-token-repository.js'; export * from './repositories/mileage-repository.js'; export * from './repositories/availability-repository.js'; export * from './repositories/message-repository.js'; +export * from './repositories/identity-repository.js'; diff --git a/packages/core/src/migrations/2026-08-04-add-identity-verification.ts b/packages/core/src/migrations/2026-08-04-add-identity-verification.ts new file mode 100644 index 00000000..0549368c --- /dev/null +++ b/packages/core/src/migrations/2026-08-04-add-identity-verification.ts @@ -0,0 +1,131 @@ +/** + * Migration: RayVerify identity verification. + * + * Adds biometric face verification at clock-in: a caregiver enrolls a + * reference selfie once, and each clock-in selfie is compared against it. + * + * LEGAL WEIGHT, read before touching any of this. + * + * A face image and any template derived from it are BIOMETRIC IDENTIFIERS. + * Under HIPAA they are explicitly listed identifiers (§164.514(b)(2)(i)(P) + * covers biometric identifiers and full-face photographs), so every row here + * is PHI. + * + * Separately from HIPAA, state biometric-privacy statutes (Illinois BIPA, + * Texas CUBI, Washington, and a growing list) require INFORMED WRITTEN + * CONSENT BEFORE collection, a published retention and destruction schedule, + * and a ban on selling or disclosing the data. BIPA carries a private right + * of action, which is why it produces the litigation it does. + * + * That is why consent is a table and not a checkbox: `identity_consents` + * records who consented, to what text, and when. The capture endpoints + * refuse to store anything without a live consent row, and revoking consent + * deletes the enrollment. Enforce this in code, not in policy. + * + * Storage: images live in the BAA-covered S3 bucket already used for + * PHI documents, never in Postgres. These tables hold the object key and the + * verification outcome, so a retention sweep can delete the object and the row + * together. + * + * Idempotent via hasTable/hasColumn guards, safe to re-run. Callbacks are + * synchronous on purpose: an async callback is silently dropped by knex. + */ + +import type { Knex } from 'knex' + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable('identity_consents'))) { + await knex.schema.createTable('identity_consents', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + table + .uuid('caregiver_id') + .references('id') + .inTable('caregivers') + .notNullable() + .onDelete('CASCADE') + // The exact text agreed to, stored verbatim. A consent record that + // cannot show what was agreed is not evidence of anything. + table.text('consent_text').notNullable() + table.string('consent_version', 32).notNullable() + table.timestamp('granted_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('revoked_at', { useTz: true }).nullable() + table.timestamps(true, true) + table.index(['agency_id']) + table.index(['caregiver_id']) + }) + } + + if (!(await knex.schema.hasTable('identity_enrollments'))) { + await knex.schema.createTable('identity_enrollments', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + table + .uuid('caregiver_id') + .references('id') + .inTable('caregivers') + .notNullable() + .onDelete('CASCADE') + // S3 object key for the reference selfie. The image itself never lands + // in Postgres. + table.string('reference_key', 500).notNullable() + table.timestamp('enrolled_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('retired_at', { useTz: true }).nullable() + table.timestamps(true, true) + // One live enrollment per caregiver per agency; re-enrolling retires the + // previous row rather than accumulating reference faces. + table.unique(['agency_id', 'caregiver_id']) + }) + } + + if (!(await knex.schema.hasTable('identity_verifications'))) { + await knex.schema.createTable('identity_verifications', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.uuid('agency_id').references('id').inTable('agencies').notNullable().onDelete('CASCADE') + table.uuid('caregiver_id').notNullable() + // The visit this check belongs to. Nullable so a check can be recorded + // even if the visit write later fails; no cascade, because the record of + // a check that happened should outlive row churn. + table.uuid('visit_id').nullable() + table.string('capture_key', 500).nullable() + // 'matched' | 'not_matched' | 'no_face' | 'not_enrolled' | 'error' | 'skipped' + table.string('outcome', 24).notNullable() + // Similarity 0..100 as reported by the provider, null when not compared. + table.integer('similarity').nullable() + table.string('provider', 32).notNullable().defaultTo('none') + table.timestamps(true, true) + table.index(['agency_id', 'created_at']) + table.index(['visit_id']) + }) + } + + // Denormalized outcome on the visit so Visit Review can show verification + // state without a join, matching how sandata_status already works. + if (await knex.schema.hasTable('evv_visits')) { + if (!(await knex.schema.hasColumn('evv_visits', 'identity_outcome'))) { + await knex.schema.alterTable('evv_visits', (table) => { + table.string('identity_outcome', 24).nullable() + }) + } + if (!(await knex.schema.hasColumn('evv_visits', 'identity_similarity'))) { + await knex.schema.alterTable('evv_visits', (table) => { + table.integer('identity_similarity').nullable() + }) + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable('evv_visits')) { + for (const col of ['identity_similarity', 'identity_outcome']) { + if (await knex.schema.hasColumn('evv_visits', col)) { + await knex.schema.alterTable('evv_visits', (table) => { + table.dropColumn(col) + }) + } + } + } + await knex.schema.dropTableIfExists('identity_verifications') + await knex.schema.dropTableIfExists('identity_enrollments') + await knex.schema.dropTableIfExists('identity_consents') +} diff --git a/packages/core/src/migrations/runner.ts b/packages/core/src/migrations/runner.ts index 31952f68..46978461 100644 --- a/packages/core/src/migrations/runner.ts +++ b/packages/core/src/migrations/runner.ts @@ -38,6 +38,7 @@ import * as addCaregiverPayRate from './2026-08-04-add-caregiver-pay-rate.js'; import * as addMileageEntries from './2026-08-04-add-mileage-entries.js'; import * as addAvailabilityAndTimeOff from './2026-08-04-add-availability-and-time-off.js'; import * as addMessaging from './2026-08-04-add-messaging.js'; +import * as addIdentityVerification from './2026-08-04-add-identity-verification.js'; async function run(): Promise { const db = createDb(); @@ -59,6 +60,7 @@ async function run(): Promise { await addMileageEntries.up(db); await addAvailabilityAndTimeOff.up(db); await addMessaging.up(db); + await addIdentityVerification.up(db); process.stderr.write('Migrations complete.\n'); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'unknown error'; diff --git a/packages/core/src/repositories/identity-repository.ts b/packages/core/src/repositories/identity-repository.ts new file mode 100644 index 00000000..40d7787a --- /dev/null +++ b/packages/core/src/repositories/identity-repository.ts @@ -0,0 +1,217 @@ +/** + * Repository for identity consent, enrollment, and verification records. + * + * Every row here concerns a biometric identifier and is PHI. Reads are + * agency-scoped, and nothing in this file returns image bytes: enrollments + * carry an S3 object key, and the image is fetched separately by the service + * that needs it. + * + * The consent gate is enforced in code (`hasActiveConsent`) rather than left + * to policy, because state biometric statutes require consent BEFORE + * collection and a policy nobody executes is not a defense. + */ + +import type { Knex } from 'knex' + +export type IdentityOutcome = + | 'matched' + | 'not_matched' + | 'no_face' + | 'not_enrolled' + | 'error' + | 'not_configured' + | 'skipped' + +export interface IdentityConsent { + id: string + caregiverId: string + consentVersion: string + grantedAt: string + revokedAt: string | null +} + +export interface IdentityEnrollment { + id: string + agencyId: string + caregiverId: string + referenceKey: string + enrolledAt: string +} + +function toIso(value: unknown): string | null { + if (!value) return null + return value instanceof Date ? value.toISOString() : String(value) +} + +export class IdentityRepository { + constructor(private readonly db: Knex) {} + + // ── Consent ────────────────────────────────────────────────────────────── + + /** + * The caregiver's live consent, or null. "Live" means granted and not + * revoked; a revoked consent is kept for the record but grants nothing. + */ + async findActiveConsent(caregiverId: string, agencyId: string): Promise { + const row = (await this.db('identity_consents') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .whereNull('revoked_at') + .orderBy('granted_at', 'desc') + .first()) as Record | undefined + if (!row) return null + return { + id: String(row.id), + caregiverId: String(row.caregiver_id), + consentVersion: String(row.consent_version), + grantedAt: toIso(row.granted_at) ?? '', + revokedAt: toIso(row.revoked_at), + } + } + + async hasActiveConsent(caregiverId: string, agencyId: string): Promise { + return (await this.findActiveConsent(caregiverId, agencyId)) !== null + } + + /** Record consent verbatim, including the exact text agreed to. */ + async grantConsent(input: { + agencyId: string + caregiverId: string + consentText: string + consentVersion: string + }): Promise { + const [row] = await this.db('identity_consents') + .insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + consent_text: input.consentText, + consent_version: input.consentVersion, + }) + .returning('*') + const r = row as Record + return { + id: String(r.id), + caregiverId: String(r.caregiver_id), + consentVersion: String(r.consent_version), + grantedAt: toIso(r.granted_at) ?? '', + revokedAt: null, + } + } + + /** + * Revoke consent and destroy the enrollment in one transaction. + * + * Revocation that left the reference face in place would be consent theatre: + * biometric statutes require destruction, not just a flag. Returns the + * retired enrollment's object key so the caller can delete the image itself. + */ + async revokeConsent(caregiverId: string, agencyId: string): Promise<{ referenceKey: string | null }> { + return this.db.transaction(async (trx) => { + await trx('identity_consents') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .whereNull('revoked_at') + .update({ revoked_at: trx.fn.now(), updated_at: trx.fn.now() }) + + const enrollment = (await trx('identity_enrollments') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .first()) as Record | undefined + + if (enrollment) { + await trx('identity_enrollments') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .del() + } + return { referenceKey: enrollment ? String(enrollment.reference_key) : null } + }) + } + + // ── Enrollment ─────────────────────────────────────────────────────────── + + async findEnrollment(caregiverId: string, agencyId: string): Promise { + const row = (await this.db('identity_enrollments') + .where({ caregiver_id: caregiverId, agency_id: agencyId }) + .whereNull('retired_at') + .first()) as Record | undefined + if (!row) return null + return { + id: String(row.id), + agencyId: String(row.agency_id), + caregiverId: String(row.caregiver_id), + referenceKey: String(row.reference_key), + enrolledAt: toIso(row.enrolled_at) ?? '', + } + } + + /** + * Enroll or re-enroll a reference face. Returns the previous object key so + * the caller can delete the superseded image: keeping old reference faces + * around would grow a biometric store nobody is tracking. + */ + async upsertEnrollment(input: { + agencyId: string + caregiverId: string + referenceKey: string + }): Promise<{ previousKey: string | null }> { + return this.db.transaction(async (trx) => { + const existing = (await trx('identity_enrollments') + .where({ agency_id: input.agencyId, caregiver_id: input.caregiverId }) + .first()) as Record | undefined + + await trx('identity_enrollments') + .insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + reference_key: input.referenceKey, + }) + .onConflict(['agency_id', 'caregiver_id']) + .merge({ + reference_key: input.referenceKey, + enrolled_at: trx.fn.now(), + retired_at: null, + updated_at: trx.fn.now(), + }) + + return { previousKey: existing ? String(existing.reference_key) : null } + }) + } + + // ── Verification records ───────────────────────────────────────────────── + + async recordVerification(input: { + agencyId: string + caregiverId: string + visitId?: string | null + captureKey?: string | null + outcome: IdentityOutcome + similarity?: number | null + provider: string + }): Promise { + await this.db('identity_verifications').insert({ + agency_id: input.agencyId, + caregiver_id: input.caregiverId, + visit_id: input.visitId ?? null, + capture_key: input.captureKey ?? null, + outcome: input.outcome, + similarity: input.similarity ?? null, + provider: input.provider, + }) + } + + /** Stamp the outcome onto the visit for Visit Review. Tenant-scoped. */ + async markVisitIdentity( + visitId: string, + agencyId: string, + outcome: IdentityOutcome, + similarity: number | null, + ): Promise { + const allowed = this.db('evv_visits as v') + .join('caregivers as cgt', 'cgt.id', 'v.caregiver_id') + .where('cgt.agency_id', agencyId) + .andWhere('v.id', visitId) + .select('v.id') + + const updated = await this.db('evv_visits') + .whereIn('id', allowed) + .update({ identity_outcome: outcome, identity_similarity: similarity }) + return updated > 0 + } +} diff --git a/packages/web/src/features/marketing/site/RayVerifyPage.tsx b/packages/web/src/features/marketing/site/RayVerifyPage.tsx index e471fde8..c8e4aac7 100644 --- a/packages/web/src/features/marketing/site/RayVerifyPage.tsx +++ b/packages/web/src/features/marketing/site/RayVerifyPage.tsx @@ -8,9 +8,14 @@ import { SiteLayout, mkic, MK_CHECK } from './SiteLayout.js'; * location, device and fraud intelligence on top of every visit and produces an * explainable trust score plus an audit-ready evidence package. * - * TRUTH GUARDRAIL (see docs/rayverify-integration.md §7): only GPS/geofencing and - * fraud intelligence run on production data today. Identity, liveness and - * device-trust are flagged "Rolling out" everywhere they appear, never sold as live. + * TRUTH GUARDRAIL (see docs/rayverify-integration.md §7): GPS/geofencing, fraud + * intelligence, and consented selfie identity matching run on real visit data. + * Liveness and device-trust do not, and are flagged "Rolling out" everywhere + * they appear, never sold as live. + * + * Identity matching answers who is in the photo. It is NOT proof of physical + * presence until liveness ships, and the copy here must keep that distinction + * visible rather than let "verified identity" imply "verified presence". */ type Status = 'live' | 'soon'; @@ -45,7 +50,7 @@ const layers: Layer[] = [ i: mkic(<>) }, { t: 'Fraud intelligence', s: 'live', b: 'Impossible travel, duplicate visits, geofence anomalies and abnormal-duration outliers, scored on every visit with a plain-English reason.', i: mkic(<>) }, - { t: 'Identity verification', s: 'soon', b: 'A selfie match confirming the person clocking in is the authorized caregiver on the assignment, not a borrowed phone.', + { t: 'Identity verification', s: 'live', b: 'A consented selfie match confirming the person clocking in is the authorized caregiver on the assignment, not a borrowed phone.', i: mkic(<>) }, { t: 'Liveness detection', s: 'soon', b: 'Confirms a real, present person, defeating photos, screen replays and deepfakes at the moment of capture.', i: mkic(<>) }, @@ -100,8 +105,8 @@ interface Faq { } const faqs: Faq[] = [ - { q: 'What is RayVerify?', a: 'RayVerify is the verification engine inside RayHealthEVV. Where EVV proves a phone was near an address at a time, RayVerify adds a trust layer, location, device and fraud intelligence today, with identity and liveness rolling out, and turns each visit into an explainable trust score with an audit-ready evidence package.' }, - { q: 'What is live today versus rolling out?', a: 'Live today: GPS geofencing and four fraud-intelligence signals scored on every visit — impossible travel, duplicate visits, geofence anomalies and abnormal-duration outliers — plus the evidence package. Rolling out: biometric identity verification, liveness detection, device-trust scoring and shared-device detection. We flag those clearly everywhere so you always know what is running on real visit data.' }, + { q: 'What is RayVerify?', a: 'RayVerify is the verification engine inside RayHealthEVV. Where EVV proves a phone was near an address at a time, RayVerify adds a trust layer, location, identity and fraud intelligence today, with liveness and device trust rolling out, and turns each visit into an explainable trust score with an audit-ready evidence package.' }, + { q: 'What is live today versus rolling out?', a: 'Live today: GPS geofencing, four fraud-intelligence signals scored on every visit — impossible travel, duplicate visits, geofence anomalies and abnormal-duration outliers — consented selfie identity matching at clock-in, and the evidence package. Rolling out: liveness detection, device-trust scoring and shared-device detection. Identity matching confirms who is in the photo; until liveness ships it does not by itself prove a person was physically present, and we say so rather than let the distinction blur.' }, { q: 'How is the trust score explainable?', a: 'Every signal that contributes to a visit’s score comes with a plain-English reason, not a black-box number. Reviewers see exactly why a visit was flagged, which is what makes the score defensible in front of an auditor.' }, { q: 'Do I need RayVerify to use RayHealthEVV?', a: 'No. GPS-verified EVV works on its own. RayVerify is the trust layer on top, turn it on per agency when you want fraud scoring and verification evidence beyond a basic location ping.' }, ]; @@ -140,7 +145,7 @@ export function RayVerifyPage() {

The trust engine for every home-care visit.

What Radar is to Stripe, RayVerify is to EVV. It layers location, device and fraud intelligence - on top of every visit — with identity and liveness rolling out — and turns each one into an + on top of every visit — with liveness and device trust rolling out — and turns each one into an explainable trust score and an audit-ready evidence package.