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.