diff --git a/backend/.env.example b/backend/.env.example index 293ff44..aa08b7d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,7 +9,34 @@ DATABASE_URL=postgresql://user:password@localhost:5432/halobestie # Valkey / Redis VALKEY_URL=redis://localhost:6379 -# Firebase -FIREBASE_PROJECT_ID=your-firebase-project-id -FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com -FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +# Control center origin (for CORS + refresh-cookie). Comma-separated list allowed. +CC_ORIGIN=http://localhost:5173 + +# --- Auth (Phase 3.4) --- + +# JWT access token signing key (HS256). Must be >= 32 chars. +AUTH_JWT_SECRET=replace-with-strong-random-32+char-secret +ACCESS_TOKEN_TTL_SECONDS=3600 +REFRESH_TOKEN_TTL_DAYS=30 + +# Fazpass (OTP provider — TBD real values once docs are available) +FAZPASS_API_KEY= +FAZPASS_BASE_URL= +FAZPASS_WEBHOOK_SECRET= + +# Google OAuth — comma-separated list of valid audience client IDs (Android, iOS). +GOOGLE_OAUTH_CLIENT_IDS= + +# Apple Sign In +APPLE_SERVICES_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= + +# First super-admin (used by seed script) +ADMIN_EMAIL=admin@halobestie.com +ADMIN_PASSWORD=ChangeMe123! + +# --- FCM (kept — only Messaging is used; Auth is self-managed) --- +# Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json) +FIREBASE_SERVICE_ACCOUNT_PATH= diff --git a/backend/package-lock.json b/backend/package-lock.json index 2788d59..09a107d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,13 +8,18 @@ "name": "halo-bestie-backend", "version": "1.0.0", "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.0.0", "@fastify/sensible": "^6.0.0", "@fastify/websocket": "^11.0.0", + "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "fastify": "^5.0.0", "firebase-admin": "^12.2.0", + "google-auth-library": "^9.15.1", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^3.2.2", "pg": "^8.12.0", "postgres": "^3.4.4", "zod": "^3.23.8" @@ -50,6 +55,26 @@ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, "node_modules/@fastify/cors": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", @@ -457,6 +482,51 @@ "node": ">=8" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -628,6 +698,12 @@ "license": "MIT", "optional": true }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -652,7 +728,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -695,7 +770,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -716,6 +790,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", @@ -772,6 +866,12 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -790,19 +890,41 @@ "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, "license": "MIT", - "optional": true + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", - "optional": true, "engines": { "node": "*" } }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -823,6 +945,15 @@ "node": ">= 0.4" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -867,6 +998,15 @@ "license": "MIT", "optional": true }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -880,6 +1020,18 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -929,6 +1081,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -956,6 +1114,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1008,8 +1175,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.5", @@ -1093,8 +1259,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/farmhash-modern": { "version": "1.1.0", @@ -1353,6 +1518,36 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1370,12 +1565,32 @@ "license": "MIT", "optional": true }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -1396,7 +1611,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -1406,7 +1620,6 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", - "optional": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -1465,12 +1678,32 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/google-auth-library": { "version": "9.15.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", - "optional": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -1526,7 +1759,6 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -1549,7 +1781,6 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", - "optional": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -1587,6 +1818,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1676,7 +1913,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1685,6 +1921,17 @@ "node": ">= 14" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1729,7 +1976,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -1739,7 +1985,6 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" }, @@ -1761,7 +2006,6 @@ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", - "optional": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -1988,6 +2232,30 @@ "lru-cache": "6.0.0" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2043,18 +2311,81 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -2079,6 +2410,43 @@ "node": ">= 6.13.0" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -2139,6 +2507,15 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -2491,6 +2868,22 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2570,6 +2963,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2582,6 +2981,12 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2645,7 +3050,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", - "optional": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2660,7 +3064,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2688,6 +3091,24 @@ "license": "MIT", "optional": true }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -2777,8 +3198,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", @@ -2863,8 +3283,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true + "license": "BSD-2-Clause" }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -2894,12 +3313,20 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index d77e69a..5c1ff20 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,13 +11,18 @@ "db:seed": "node src/db/seed.js" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.0.0", "@fastify/sensible": "^6.0.0", "@fastify/websocket": "^11.0.0", + "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "fastify": "^5.0.0", "firebase-admin": "^12.2.0", + "google-auth-library": "^9.15.1", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^3.2.2", "pg": "^8.12.0", "postgres": "^3.4.4", "zod": "^3.23.8" diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index 0bfea5a..b1609bb 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -1,5 +1,6 @@ import Fastify from 'fastify' import cors from '@fastify/cors' +import cookie from '@fastify/cookie' import sensible from '@fastify/sensible' import { mitraManagementRoutes } from './routes/internal/mitra.routes.js' import { ccUserRoutes } from './routes/internal/cc-user.routes.js' @@ -13,7 +14,13 @@ import { errorHandler } from './plugins/error-handler.js' export const buildInternalApp = async () => { const app = Fastify({ logger: true }) - await app.register(cors, { origin: true }) + // CORS: control center origin must be allowed with credentials for httpOnly refresh cookie + const ccOrigin = process.env.CC_ORIGIN + await app.register(cors, { + origin: ccOrigin ? ccOrigin.split(',').map((s) => s.trim()) : true, + credentials: true, + }) + await app.register(cookie) await app.register(sensible) app.setErrorHandler(errorHandler) diff --git a/backend/src/app.public.js b/backend/src/app.public.js index c50caaf..14c87f7 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -1,7 +1,7 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import sensible from '@fastify/sensible' -import { customerRoutes } from './routes/public/customer.routes.js' +import { sharedAuthRoutes } from './routes/public/shared.auth.routes.js' import { clientAuthRoutes } from './routes/public/client.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' @@ -20,7 +20,7 @@ export const buildPublicApp = async () => { await registerWebSocketPlugin(app) app.setErrorHandler(errorHandler) - app.register(customerRoutes, { prefix: '/api/shared/customer' }) + app.register(sharedAuthRoutes, { prefix: '/api/shared/auth' }) app.register(sharedConfigRoutes, { prefix: '/api/shared/config' }) app.register(sharedChatRoutes, { prefix: '/api/shared' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) diff --git a/backend/src/constants.js b/backend/src/constants.js index fffe953..e96077c 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -2,6 +2,7 @@ export const UserType = Object.freeze({ CUSTOMER: 'customer', MITRA: 'mitra', + CC_USER: 'cc_user', }) // Chat session statuses diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index ac6127a..372a543 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -1,13 +1,10 @@ import 'dotenv/config' -import admin from 'firebase-admin' import { getDb } from './client.js' -import { initFirebase } from '../plugins/firebase.js' +import { hashPassword } from '../services/password.service.js' const sql = getDb() const seed = async () => { - initFirebase() - // Create super_admin role const [role] = await sql` INSERT INTO roles (name, permissions) @@ -24,21 +21,14 @@ const seed = async () => { RETURNING id ` - // Create first super admin user in Firebase - const email = process.env.SEED_ADMIN_EMAIL || 'admin@halobestie.com' - const password = process.env.SEED_ADMIN_PASSWORD || 'ChangeMe123!' - - let firebaseUser - try { - firebaseUser = await admin.auth().getUserByEmail(email) - } catch { - firebaseUser = await admin.auth().createUser({ email, password, displayName: 'Super Admin' }) - } + const email = process.env.ADMIN_EMAIL || process.env.SEED_ADMIN_EMAIL || 'admin@halobestie.com' + const password = process.env.ADMIN_PASSWORD || process.env.SEED_ADMIN_PASSWORD || 'ChangeMe123!' + const passwordHash = await hashPassword(password) await sql` - INSERT INTO control_center_users (firebase_uid, email, display_name, role_id) - VALUES (${firebaseUser.uid}, ${email}, 'Super Admin', ${role.id}) - ON CONFLICT (email) DO NOTHING + INSERT INTO control_center_users (email, display_name, role_id, password_hash) + VALUES (${email}, 'Super Admin', ${role.id}, ${passwordHash}) + ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash ` console.log(`Seed complete. Admin: ${email}`) diff --git a/backend/src/plugins/auth.js b/backend/src/plugins/auth.js index 8ae85df..76e3c33 100644 --- a/backend/src/plugins/auth.js +++ b/backend/src/plugins/auth.js @@ -1,8 +1,13 @@ -import { verifyFirebaseToken } from './firebase.js' +import { verifyAccessToken } from '../services/token.service.js' /** - * Fastify preHandler — verifies Firebase JWT and attaches decoded token to request. - * Usage: add as preHandler on any route that requires authentication. + * Fastify preHandler — verifies our JWT access token and attaches claims to request.auth. + * + * On success: request.auth = { userType, userId, sessionId } + * On failure: returns 401 UNAUTHORIZED and short-circuits the handler. + * + * Future hook: if Valkey-based instant revocation is enabled, add a + * SISMEMBER revoked_sessions check here before accepting. */ export const authenticate = async (request, reply) => { const authHeader = request.headers.authorization @@ -15,18 +20,25 @@ export const authenticate = async (request, reply) => { const token = authHeader.slice(7) try { - request.firebaseUser = await verifyFirebaseToken(token) + const claims = verifyAccessToken(token) + if (!claims.userId || !claims.userType || !claims.sessionId) { + return reply.code(401).send({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Invalid token claims' }, + }) + } + request.auth = claims } catch (err) { - console.error('Auth failed:', err.code || err.message, '| token preview:', token.substring(0, 20) + '...') - return reply.code(401).send({ + return reply.code(err.statusCode || 401).send({ success: false, - error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' }, + error: { code: err.code || 'UNAUTHORIZED', message: err.message || 'Invalid or expired token' }, }) } } /** - * Returns a preHandler that checks if the CC user has the required permission. + * Returns a preHandler that checks if the authenticated CC user has the required permission. + * Requires `attachCcUser` (or equivalent) to have run earlier and set request.ccUser. * Usage: requirePermission('mitra', 'create') */ export const requirePermission = (resource, action) => { diff --git a/backend/src/plugins/firebase.js b/backend/src/plugins/firebase.js index 65dd25e..b1f397a 100644 --- a/backend/src/plugins/firebase.js +++ b/backend/src/plugins/firebase.js @@ -7,6 +7,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) let initialized = false +/** + * Initializes Firebase Admin SDK. Phase 3.4+ only uses this for FCM (messaging); + * authentication is handled by our own token service. + */ export const initFirebase = () => { if (initialized) return @@ -19,7 +23,3 @@ export const initFirebase = () => { }) initialized = true } - -export const verifyFirebaseToken = async (token) => { - return admin.auth().verifyIdToken(token) -} diff --git a/backend/src/plugins/websocket.js b/backend/src/plugins/websocket.js index c06dc5d..da319bc 100644 --- a/backend/src/plugins/websocket.js +++ b/backend/src/plugins/websocket.js @@ -1,7 +1,5 @@ import websocket from '@fastify/websocket' -import { verifyFirebaseToken } from './firebase.js' -import { getCustomerByFirebaseUid } from '../services/customer.service.js' -import { getMitraByFirebaseUid } from '../services/mitra.service.js' +import { verifyAccessToken } from '../services/token.service.js' import { subscribe, publish } from './valkey.js' import { UserType, WsMessage } from '../constants.js' @@ -64,18 +62,15 @@ export const registerWebSocketRoute = (app) => { // Handle auth message if (msg.type === WsMessage.AUTH) { try { - const decoded = await verifyFirebaseToken(msg.token) - const customer = await getCustomerByFirebaseUid(decoded.uid) - const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid) - - if (!customer && !mitra) { - send({ type: WsMessage.ERROR, message: 'Account not found' }) + const claims = verifyAccessToken(msg.token) + if (claims.userType !== UserType.CUSTOMER && claims.userType !== UserType.MITRA) { + send({ type: WsMessage.ERROR, message: 'Unsupported user type for websocket' }) socket.close() return } - const userType = customer ? UserType.CUSTOMER : UserType.MITRA - const userId = customer ? customer.id : mitra.id + const userType = claims.userType + const userId = claims.userId const sessionId = msg.session_id authenticatedUser = { type: userType, id: userId, sessionId } diff --git a/backend/src/routes/internal/auth.routes.js b/backend/src/routes/internal/auth.routes.js index 49b7fa5..36e3f28 100644 --- a/backend/src/routes/internal/auth.routes.js +++ b/backend/src/routes/internal/auth.routes.js @@ -1,17 +1,127 @@ import { authenticate } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' +import { + signInCcUser, + refreshTokens, + logout, +} from '../../services/auth.service.js' +import { UserType } from '../../constants.js' -export const internalAuthRoutes = async (app) => { - app.post('/verify', { preHandler: authenticate }, async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) - if (!user) { - return reply.code(403).send({ - success: false, - error: { code: 'FORBIDDEN', message: 'Not a control center user' }, - }) - } - // Attach to request for downstream permission checks - request.ccUser = user - return reply.send({ success: true, data: user }) +const REFRESH_COOKIE_NAME = 'cc_refresh_token' + +const extractDeviceInfo = (request) => ({ + user_agent: request.headers['user-agent'] || null, + ip: request.ip || null, +}) + +const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ + success: false, + error: { code: err.code || 'INTERNAL', message: err.message }, +}) + +const cookieOpts = () => ({ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + path: '/', +}) + +const setRefreshCookie = (reply, refreshToken, expiresAt) => { + reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, { + ...cookieOpts(), + expires: new Date(expiresAt), + }) +} + +const clearRefreshCookie = (reply) => { + reply.clearCookie(REFRESH_COOKIE_NAME, cookieOpts()) +} + +export const internalAuthRoutes = async (app) => { + app.post('/login', async (request, reply) => { + const { email, password } = request.body || {} + if (!email || !password) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'email and password are required' }, + }) + } + try { + const { tokens, profile } = await signInCcUser({ + email, + password, + deviceInfo: extractDeviceInfo(request), + }) + setRefreshCookie(reply, tokens.refresh_token, tokens.refresh_token_expires_at) + return reply.send({ + success: true, + data: { + access_token: tokens.access_token, + access_token_expires_in: tokens.access_token_expires_in, + profile, + }, + }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + app.post('/refresh', async (request, reply) => { + const refreshToken = request.cookies?.[REFRESH_COOKIE_NAME] + if (!refreshToken) { + return reply.code(401).send({ + success: false, + error: { code: 'REFRESH_MISSING', message: 'Refresh token missing' }, + }) + } + try { + const { tokens, profile } = await refreshTokens({ + refreshToken, + deviceInfo: extractDeviceInfo(request), + }) + setRefreshCookie(reply, tokens.refresh_token, tokens.refresh_token_expires_at) + return reply.send({ + success: true, + data: { + access_token: tokens.access_token, + access_token_expires_in: tokens.access_token_expires_in, + profile, + }, + }) + } catch (err) { + clearRefreshCookie(reply) + return sendAuthError(reply, err) + } + }) + + app.post('/logout', { preHandler: authenticate }, async (request, reply) => { + await logout({ sessionId: request.auth.sessionId }) + clearRefreshCookie(reply) + return reply.send({ success: true }) + }) + + app.get('/me', { preHandler: authenticate }, async (request, reply) => { + if (request.auth.userType !== UserType.CC_USER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Control center account required' }, + }) + } + const user = await getCcUserById(request.auth.userId) + if (!user) { + return reply.code(404).send({ + success: false, + error: { code: 'NOT_FOUND', message: 'Control center account not found' }, + }) + } + return reply.send({ + success: true, + data: { + id: user.id, + email: user.email, + display_name: user.display_name, + role: user.role, + }, + }) }) } diff --git a/backend/src/routes/internal/cc-user.routes.js b/backend/src/routes/internal/cc-user.routes.js index d524222..940bf71 100644 --- a/backend/src/routes/internal/cc-user.routes.js +++ b/backend/src/routes/internal/cc-user.routes.js @@ -1,33 +1,54 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid, createCcUser, listCcUsers } from '../../services/cc-user.service.js' +import { + getCcUserById, + createCcUserWithPassword, + listCcUsers, + updateCcUserPasswordHash, +} from '../../services/cc-user.service.js' +import { + hashPassword, + verifyPassword, + validatePasswordComplexity, +} from '../../services/password.service.js' +import { UserType } from '../../constants.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } +const sendValidation = (reply, err) => reply.code(err.statusCode || 422).send({ + success: false, + error: { code: err.code || 'VALIDATION_ERROR', message: err.message }, +}) + export const ccUserRoutes = async (app) => { + // Create CC user (with initial password) app.post('/', { preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'create')], }, async (request, reply) => { - const { email, display_name, role_id } = request.body ?? {} - if (!email || !display_name || !role_id) { - return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'email, display_name, and role_id are required' } }) + const { email, display_name, role_id, password } = request.body ?? {} + if (!email || !display_name || !role_id || !password) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'email, display_name, role_id, and password are required' }, + }) } - - // Create Firebase user with temporary password — admin will share credentials verbally - const { initFirebase } = await import('../../plugins/firebase.js') - const admin = (await import('firebase-admin')).default - initFirebase() - - const tempPassword = Math.random().toString(36).slice(-10) + 'A1!' - const firebaseUser = await admin.auth().createUser({ email, password: tempPassword }) - - const user = await createCcUser({ firebase_uid: firebaseUser.uid, email, display_name, role_id }) + try { + validatePasswordComplexity(password) + } catch (err) { + return sendValidation(reply, err) + } + const passwordHash = await hashPassword(password) + const user = await createCcUserWithPassword({ email, display_name, role_id, password_hash: passwordHash }) return reply.code(201).send({ success: true, data: user }) }) + // List CC users app.get('/', { preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')], }, async (request, reply) => { @@ -35,4 +56,53 @@ export const ccUserRoutes = async (app) => { const result = await listCcUsers({ page: Number(page), limit: Number(limit) }) return reply.send({ success: true, data: result }) }) + + // Self-service password change + app.patch('/me/password', { + preHandler: [authenticate, attachCcUser], + }, async (request, reply) => { + const { current_password, new_password } = request.body || {} + if (!current_password || !new_password) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'current_password and new_password are required' }, + }) + } + const ok = await verifyPassword(current_password, request.ccUser.password_hash) + if (!ok) { + return reply.code(401).send({ + success: false, + error: { code: 'INVALID_CREDENTIALS', message: 'Current password is incorrect' }, + }) + } + try { + validatePasswordComplexity(new_password) + } catch (err) { + return sendValidation(reply, err) + } + const hash = await hashPassword(new_password) + await updateCcUserPasswordHash(request.ccUser.id, hash) + return reply.send({ success: true }) + }) + + // Admin-forced password reset + app.patch('/:id/password', { + preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'update')], + }, async (request, reply) => { + const { new_password } = request.body || {} + if (!new_password) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'new_password is required' }, + }) + } + try { + validatePasswordComplexity(new_password) + } catch (err) { + return sendValidation(reply, err) + } + const hash = await hashPassword(new_password) + await updateCcUserPasswordHash(request.params.id, hash) + return reply.send({ success: true }) + }) } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 0f3332c..e512fe2 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -1,5 +1,6 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' +import { UserType } from '../../constants.js' import { getAnonymityConfig, setAnonymityConfig, getMaxCustomersPerMitra, setMaxCustomersPerMitra, @@ -11,7 +12,10 @@ import { } from '../../services/config.service.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } diff --git a/backend/src/routes/internal/mitra-activity.routes.js b/backend/src/routes/internal/mitra-activity.routes.js index 622ae8f..35765a8 100644 --- a/backend/src/routes/internal/mitra-activity.routes.js +++ b/backend/src/routes/internal/mitra-activity.routes.js @@ -1,13 +1,14 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' import { getMitraActivityLog, getMitraActivitySummary } from '../../services/mitra-activity.service.js' +import { UserType } from '../../constants.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) - if (!user) return reply.code(403).send({ - success: false, - error: { code: 'FORBIDDEN', message: 'Not a control center user' }, - }) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) + if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } diff --git a/backend/src/routes/internal/mitra.routes.js b/backend/src/routes/internal/mitra.routes.js index de19454..c9f3b96 100644 --- a/backend/src/routes/internal/mitra.routes.js +++ b/backend/src/routes/internal/mitra.routes.js @@ -1,10 +1,14 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' import { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js' import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js' +import { UserType } from '../../constants.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } diff --git a/backend/src/routes/internal/roles.routes.js b/backend/src/routes/internal/roles.routes.js index 382cfb6..0facb2e 100644 --- a/backend/src/routes/internal/roles.routes.js +++ b/backend/src/routes/internal/roles.routes.js @@ -1,9 +1,13 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' import { listRoles } from '../../services/roles.service.js' +import { UserType } from '../../constants.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } diff --git a/backend/src/routes/internal/session.routes.js b/backend/src/routes/internal/session.routes.js index a5df049..b9d650d 100644 --- a/backend/src/routes/internal/session.routes.js +++ b/backend/src/routes/internal/session.routes.js @@ -1,11 +1,15 @@ import { authenticate, requirePermission } from '../../plugins/auth.js' -import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getCcUserById } from '../../services/cc-user.service.js' import { listSessions, getSessionById, rerouteSession } from '../../services/session.service.js' import { getSessionSensitivityLog } from '../../services/sensitivity.service.js' import { getDashboardStats } from '../../services/dashboard.service.js' +import { UserType } from '../../constants.js' const attachCcUser = async (request, reply) => { - const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CC_USER) { + return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) + } + const user = await getCcUserById(request.auth.userId) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } }) request.ccUser = user } diff --git a/backend/src/routes/public/client.auth.routes.js b/backend/src/routes/public/client.auth.routes.js index 2726814..924a057 100644 --- a/backend/src/routes/public/client.auth.routes.js +++ b/backend/src/routes/public/client.auth.routes.js @@ -1,25 +1,134 @@ import { authenticate } from '../../plugins/auth.js' -import { getOrCreateCustomer, getCustomerByFirebaseUid, updateCustomerDisplayName } from '../../services/customer.service.js' +import { getCustomerById, updateCustomerDisplayName } from '../../services/customer.service.js' +import { + completeCustomerPhoneSignIn, + signInWithGoogle, + signInWithApple, +} from '../../services/auth.service.js' +import { requestOtp, verifyOtp } from '../../services/otp.service.js' +import { UserType } from '../../constants.js' + +const extractDeviceInfo = (request) => ({ + user_agent: request.headers['user-agent'] || null, + ip: request.ip || null, +}) + +const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ + success: false, + error: { code: err.code || 'INTERNAL', message: err.message }, +}) export const clientAuthRoutes = async (app) => { - app.post('/verify', { preHandler: authenticate }, async (request, reply) => { - const { uid, phone_number, name } = request.firebaseUser - const customer = await getOrCreateCustomer({ - firebase_uid: uid, - phone: phone_number || null, - display_name: name || null, - }) - return reply.send({ success: true, data: customer }) + // --- Phone OTP --- + + app.post('/otp/request', async (request, reply) => { + const { phone, channel } = request.body || {} + try { + const result = await requestOtp({ + phone, + userType: UserType.CUSTOMER, + ipAddress: request.ip, + channel, + }) + return reply.send({ success: true, data: result }) + } catch (err) { + return sendAuthError(reply, err) + } }) - app.patch('/profile', { preHandler: authenticate }, async (request, reply) => { - const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) + app.post('/otp/verify', async (request, reply) => { + const { otp_request_id, code, anonymous_customer_id } = request.body || {} + try { + const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code }) + if (user_type !== UserType.CUSTOMER) { + return reply.code(400).send({ + success: false, + error: { code: 'WRONG_FLOW', message: 'This OTP was issued for a different user type' }, + }) + } + const { tokens, profile } = await completeCustomerPhoneSignIn({ + phone, + anonymousCustomerId: anonymous_customer_id || null, + deviceInfo: extractDeviceInfo(request), + }) + return reply.send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + // --- Google --- + + app.post('/google', async (request, reply) => { + const { id_token, anonymous_customer_id } = request.body || {} + if (!id_token) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'id_token is required' }, + }) + } + try { + const { tokens, profile } = await signInWithGoogle({ + idToken: id_token, + anonymousCustomerId: anonymous_customer_id || null, + deviceInfo: extractDeviceInfo(request), + }) + return reply.send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + // --- Apple --- + + app.post('/apple', async (request, reply) => { + const { id_token, anonymous_customer_id } = request.body || {} + if (!id_token) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'id_token is required' }, + }) + } + try { + const { tokens, profile } = await signInWithApple({ + idToken: id_token, + anonymousCustomerId: anonymous_customer_id || null, + deviceInfo: extractDeviceInfo(request), + }) + return reply.send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + // --- Current user profile --- + + app.get('/me', { preHandler: authenticate }, async (request, reply) => { + if (request.auth.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } + const customer = await getCustomerById(request.auth.userId) if (!customer) { return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Customer account not found' }, }) } + return reply.send({ success: true, data: customer }) + }) + + // --- Update display name --- + + app.patch('/profile', { preHandler: authenticate }, async (request, reply) => { + if (request.auth.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } const { display_name } = request.body || {} if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) { return reply.code(422).send({ @@ -27,7 +136,7 @@ export const clientAuthRoutes = async (app) => { error: { code: 'VALIDATION_ERROR', message: 'display_name is required' }, }) } - const updated = await updateCustomerDisplayName(customer.id, display_name.trim()) + const updated = await updateCustomerDisplayName(request.auth.userId, display_name.trim()) return reply.send({ success: true, data: updated }) }) } diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index c31fcd4..ab41fb3 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -1,13 +1,19 @@ import { authenticate } from '../../plugins/auth.js' -import { getCustomerByFirebaseUid } from '../../services/customer.service.js' +import { getCustomerById } from '../../services/customer.service.js' import { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js' import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js' import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' import { requestExtension } from '../../services/extension.service.js' -import { EndedBy, TopicSensitivity } from '../../constants.js' +import { EndedBy, TopicSensitivity, UserType } from '../../constants.js' const resolveCustomer = async (request, reply) => { - const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.CUSTOMER) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer account required' }, + }) + } + const customer = await getCustomerById(request.auth.userId) if (!customer) { return reply.code(404).send({ success: false, diff --git a/backend/src/routes/public/customer.routes.js b/backend/src/routes/public/customer.routes.js deleted file mode 100644 index 5a39d03..0000000 --- a/backend/src/routes/public/customer.routes.js +++ /dev/null @@ -1,32 +0,0 @@ -import { authenticate } from '../../plugins/auth.js' -import { createAnonymousCustomer, linkCustomerAccount } from '../../services/customer.service.js' - -export const customerRoutes = async (app) => { - app.post('/anonymous', { preHandler: authenticate }, async (request, reply) => { - const { display_name } = request.body ?? {} - if (!display_name?.trim()) { - return reply.code(422).send({ - success: false, - error: { code: 'DISPLAY_NAME_REQUIRED', message: 'Display name is required' }, - }) - } - const firebase_uid = request.firebaseUser.uid - const customer = await createAnonymousCustomer({ display_name: display_name.trim(), firebase_uid }) - return reply.code(201).send({ success: true, data: customer }) - }) - - app.post('/link', { preHandler: authenticate }, async (request, reply) => { - const { customer_id } = request.body ?? {} - const firebase_uid = request.firebaseUser.uid - - if (!customer_id) { - return reply.code(422).send({ - success: false, - error: { code: 'VALIDATION_ERROR', message: 'customer_id is required' }, - }) - } - - const customer = await linkCustomerAccount({ customer_id, firebase_uid }) - return reply.send({ success: true, data: customer }) - }) -} diff --git a/backend/src/routes/public/mitra.auth.routes.js b/backend/src/routes/public/mitra.auth.routes.js index 37e406a..65a2449 100644 --- a/backend/src/routes/public/mitra.auth.routes.js +++ b/backend/src/routes/public/mitra.auth.routes.js @@ -1,44 +1,75 @@ import { authenticate } from '../../plugins/auth.js' -import { getMitraByFirebaseUid, getMitraByPhone, setMitraFirebaseUid } from '../../services/mitra.service.js' +import { getMitraById } from '../../services/mitra.service.js' +import { completeMitraPhoneSignIn } from '../../services/auth.service.js' +import { requestOtp, verifyOtp } from '../../services/otp.service.js' +import { UserType } from '../../constants.js' + +const extractDeviceInfo = (request) => ({ + user_agent: request.headers['user-agent'] || null, + ip: request.ip || null, +}) + +const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ + success: false, + error: { code: err.code || 'INTERNAL', message: err.message }, +}) export const mitraAuthRoutes = async (app) => { - app.post('/verify', { preHandler: authenticate }, async (request, reply) => { - const { uid, phone_number } = request.firebaseUser - - // First try lookup by firebase_uid (returning user) - let mitra = await getMitraByFirebaseUid(uid) - - // First-time login: link firebase_uid to mitra record via phone number - if (!mitra && phone_number) { - mitra = await getMitraByPhone(phone_number) - if (mitra) { - await setMitraFirebaseUid(mitra.id, uid) - } + app.post('/otp/request', async (request, reply) => { + const { phone, channel } = request.body || {} + try { + const result = await requestOtp({ + phone, + userType: UserType.MITRA, + ipAddress: request.ip, + channel, + }) + return reply.send({ success: true, data: result }) + } catch (err) { + return sendAuthError(reply, err) } + }) + app.post('/otp/verify', async (request, reply) => { + const { otp_request_id, code } = request.body || {} + try { + const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code }) + if (user_type !== UserType.MITRA) { + return reply.code(400).send({ + success: false, + error: { code: 'WRONG_FLOW', message: 'This OTP was issued for a different user type' }, + }) + } + const { tokens, profile } = await completeMitraPhoneSignIn({ + phone, + deviceInfo: extractDeviceInfo(request), + }) + if (!profile.is_active) { + return reply.code(403).send({ + success: false, + error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' }, + }) + } + return reply.send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + app.get('/me', { preHandler: authenticate }, async (request, reply) => { + if (request.auth.userType !== UserType.MITRA) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Mitra account required' }, + }) + } + const mitra = await getMitraById(request.auth.userId) if (!mitra) { return reply.code(404).send({ success: false, - error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found. Contact your administrator.' }, + error: { code: 'NOT_FOUND', message: 'Mitra account not found' }, }) } - - if (!mitra.is_active) { - return reply.code(403).send({ - success: false, - error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' }, - }) - } - - return reply.send({ - success: true, - data: { - id: mitra.id, - display_name: mitra.display_name, - phone: mitra.phone, - is_active: mitra.is_active, - created_at: mitra.created_at, - }, - }) + return reply.send({ success: true, data: mitra }) }) } diff --git a/backend/src/routes/public/mitra.chat.routes.js b/backend/src/routes/public/mitra.chat.routes.js index 004a5cb..f53db85 100644 --- a/backend/src/routes/public/mitra.chat.routes.js +++ b/backend/src/routes/public/mitra.chat.routes.js @@ -1,12 +1,18 @@ import { authenticate } from '../../plugins/auth.js' -import { getMitraByFirebaseUid } from '../../services/mitra.service.js' +import { getMitraById } from '../../services/mitra.service.js' import { acceptPairingRequest, declinePairingRequest, getSessionStatus, getPendingRequestsForMitra } from '../../services/pairing.service.js' import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js' import { respondToExtension } from '../../services/extension.service.js' -import { EndedBy } from '../../constants.js' +import { EndedBy, UserType } from '../../constants.js' const resolveMitra = async (request, reply) => { - const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.MITRA) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Mitra account required' }, + }) + } + const mitra = await getMitraById(request.auth.userId) if (!mitra) { return reply.code(404).send({ success: false, diff --git a/backend/src/routes/public/mitra.status.routes.js b/backend/src/routes/public/mitra.status.routes.js index 5a8927d..22038b3 100644 --- a/backend/src/routes/public/mitra.status.routes.js +++ b/backend/src/routes/public/mitra.status.routes.js @@ -1,11 +1,17 @@ import { authenticate } from '../../plugins/auth.js' -import { getMitraByFirebaseUid } from '../../services/mitra.service.js' +import { getMitraById } from '../../services/mitra.service.js' import * as mitraStatusService from '../../services/mitra-status.service.js' +import { UserType } from '../../constants.js' export const mitraStatusRoutes = async (app) => { - // Resolve mitra from Firebase token const resolveMitra = async (request, reply) => { - const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) + if (request.auth?.userType !== UserType.MITRA) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Mitra account required' }, + }) + } + const mitra = await getMitraById(request.auth.userId) if (!mitra) { return reply.code(404).send({ success: false, diff --git a/backend/src/routes/public/shared.auth.routes.js b/backend/src/routes/public/shared.auth.routes.js new file mode 100644 index 0000000..6108e77 --- /dev/null +++ b/backend/src/routes/public/shared.auth.routes.js @@ -0,0 +1,54 @@ +import { authenticate } from '../../plugins/auth.js' +import { + signInAnonymous, + refreshTokens, + logout, +} from '../../services/auth.service.js' + +const extractDeviceInfo = (request) => ({ + user_agent: request.headers['user-agent'] || null, + ip: request.ip || null, +}) + +const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({ + success: false, + error: { code: err.code || 'INTERNAL', message: err.message }, +}) + +export const sharedAuthRoutes = async (app) => { + // Issue an anonymous customer session + app.post('/anonymous', async (request, reply) => { + try { + const { tokens, profile } = await signInAnonymous({ deviceInfo: extractDeviceInfo(request) }) + return reply.code(201).send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + // Rotate refresh token + app.post('/refresh', async (request, reply) => { + const { refresh_token } = request.body || {} + if (!refresh_token) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'refresh_token is required' }, + }) + } + try { + const { tokens, profile } = await refreshTokens({ + refreshToken: refresh_token, + deviceInfo: extractDeviceInfo(request), + }) + return reply.send({ success: true, data: { ...tokens, profile } }) + } catch (err) { + return sendAuthError(reply, err) + } + }) + + // Logout — revoke current session + app.post('/logout', { preHandler: authenticate }, async (request, reply) => { + await logout({ sessionId: request.auth.sessionId }) + return reply.send({ success: true }) + }) +} diff --git a/backend/src/routes/public/shared.chat.routes.js b/backend/src/routes/public/shared.chat.routes.js index c03a602..be8b372 100644 --- a/backend/src/routes/public/shared.chat.routes.js +++ b/backend/src/routes/public/shared.chat.routes.js @@ -1,6 +1,4 @@ import { authenticate } from '../../plugins/auth.js' -import { getCustomerByFirebaseUid } from '../../services/customer.service.js' -import { getMitraByFirebaseUid } from '../../services/mitra.service.js' import { getMessages } from '../../services/chat.service.js' import { getSessionClosures } from '../../services/closure.service.js' import { registerDeviceToken } from '../../services/notification.service.js' @@ -11,22 +9,14 @@ import { TopicSensitivity, UserType } from '../../constants.js' const sql = getDb() const resolveUser = async (request, reply) => { - const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) - if (customer) { - request.userType = UserType.CUSTOMER - request.userId = customer.id - return + if (request.auth?.userType !== UserType.CUSTOMER && request.auth?.userType !== UserType.MITRA) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Customer or mitra account required' }, + }) } - const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid) - if (mitra) { - request.userType = UserType.MITRA - request.userId = mitra.id - return - } - return reply.code(404).send({ - success: false, - error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found' }, - }) + request.userType = request.auth.userType + request.userId = request.auth.userId } // Verify session belongs to the authenticated user diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js new file mode 100644 index 0000000..25836a8 --- /dev/null +++ b/backend/src/services/auth.service.js @@ -0,0 +1,235 @@ +import crypto from 'node:crypto' +import { + getCustomerById, + getCustomerByPhone, + getCustomerByGoogleSub, + getCustomerByAppleSub, + createAnonymousCustomerV2, + createCustomerWithIdentity, + upgradeCustomerIdentity, +} from './customer.service.js' +import { + getMitraByPhone, + getMitraById, + createMitra, +} from './mitra.service.js' +import { + getCcUserByEmail, + getCcUserById, + incrementCcUserFailedLogin, + resetCcUserFailedLogin, +} from './cc-user.service.js' +import { verifyGoogleIdToken, verifyAppleIdToken } from './social-identity.service.js' +import { verifyPassword } from './password.service.js' +import { issueTokens, refreshTokens as rotateRefresh, revokeSession } from './token.service.js' +import { getCcLoginLockoutConfig } from './config.service.js' +import { UserType } from '../constants.js' + +const generateAnonymousDisplayName = () => { + const n = crypto.randomInt(1000, 10000) + return `Teman Anonim #${n}` +} + +export class AuthError extends Error { + constructor(message, code, statusCode) { + super(message) + this.code = code + this.statusCode = statusCode + } +} + +const normalizeIdentityConflict = async ({ existing, anonymousCustomerId }) => { + // If an authenticated identity is already linked to a DIFFERENT customer, + // reject. Merge is deferred per PRD. + if (existing && anonymousCustomerId && existing.id !== anonymousCustomerId) { + throw new AuthError( + 'This account is already linked to another session. Please log out first.', + 'IDENTITY_ALREADY_LINKED', 409, + ) + } +} + +// --- Anonymous --- + +export const signInAnonymous = async ({ deviceInfo } = {}) => { + const customer = await createAnonymousCustomerV2({ + display_name: generateAnonymousDisplayName(), + }) + const tokens = await issueTokens({ + userType: UserType.CUSTOMER, + userId: customer.id, + deviceInfo, + }) + return { tokens, profile: customer } +} + +// --- Phone OTP — Customer --- + +export const completeCustomerPhoneSignIn = async ({ phone, anonymousCustomerId, deviceInfo }) => { + const existing = await getCustomerByPhone(phone) + await normalizeIdentityConflict({ existing, anonymousCustomerId }) + + let customer + if (existing) { + customer = existing + } else if (anonymousCustomerId) { + customer = await upgradeCustomerIdentity(anonymousCustomerId, { phone }) + } else { + customer = await createCustomerWithIdentity({ phone, display_name: null }) + } + + const tokens = await issueTokens({ + userType: UserType.CUSTOMER, + userId: customer.id, + deviceInfo, + }) + return { tokens, profile: customer } +} + +// --- Phone OTP — Mitra --- + +export const completeMitraPhoneSignIn = async ({ phone, deviceInfo }) => { + let mitra = await getMitraByPhone(phone) + if (!mitra) { + mitra = await createMitra({ phone, display_name: phone }) + } + const tokens = await issueTokens({ + userType: UserType.MITRA, + userId: mitra.id, + deviceInfo, + }) + return { tokens, profile: mitra } +} + +// --- Google (customer only) --- + +export const signInWithGoogle = async ({ idToken, anonymousCustomerId, deviceInfo }) => { + const google = await verifyGoogleIdToken(idToken) + const existing = await getCustomerByGoogleSub(google.sub) + await normalizeIdentityConflict({ existing, anonymousCustomerId }) + + let customer + if (existing) { + customer = existing + } else if (anonymousCustomerId) { + customer = await upgradeCustomerIdentity(anonymousCustomerId, { + google_sub: google.sub, + email: google.email, + display_name: google.name, + }) + } else { + customer = await createCustomerWithIdentity({ + google_sub: google.sub, + email: google.email, + display_name: google.name, + }) + } + + const tokens = await issueTokens({ + userType: UserType.CUSTOMER, + userId: customer.id, + deviceInfo, + }) + return { tokens, profile: customer } +} + +// --- Apple (customer only) --- + +export const signInWithApple = async ({ idToken, anonymousCustomerId, deviceInfo }) => { + const apple = await verifyAppleIdToken(idToken) + const existing = await getCustomerByAppleSub(apple.sub) + await normalizeIdentityConflict({ existing, anonymousCustomerId }) + + let customer + if (existing) { + customer = existing + } else if (anonymousCustomerId) { + customer = await upgradeCustomerIdentity(anonymousCustomerId, { + apple_sub: apple.sub, + email: apple.email, + }) + } else { + customer = await createCustomerWithIdentity({ + apple_sub: apple.sub, + email: apple.email, + display_name: null, + }) + } + + const tokens = await issueTokens({ + userType: UserType.CUSTOMER, + userId: customer.id, + deviceInfo, + }) + return { tokens, profile: customer } +} + +// --- Control center email/password --- + +export const signInCcUser = async ({ email, password, deviceInfo }) => { + const user = await getCcUserByEmail(email) + // Constant-time response — same branch for "not found" vs "wrong password" OR lockout + if (!user) { + throw new AuthError('Invalid credentials', 'INVALID_CREDENTIALS', 401) + } + + // Lockout check + if (user.lockout_until && new Date(user.lockout_until) > new Date()) { + throw new AuthError( + `Account locked until ${user.lockout_until}. Try again later.`, + 'ACCOUNT_LOCKED', 423, + ) + } + + const ok = await verifyPassword(password, user.password_hash) + if (!ok) { + const { max_attempts, lockout_minutes } = await getCcLoginLockoutConfig() + await incrementCcUserFailedLogin(user.id, lockout_minutes, max_attempts) + throw new AuthError('Invalid credentials', 'INVALID_CREDENTIALS', 401) + } + + await resetCcUserFailedLogin(user.id) + + const tokens = await issueTokens({ + userType: UserType.CC_USER, + userId: user.id, + deviceInfo, + }) + return { + tokens, + profile: { + id: user.id, + email: user.email, + display_name: user.display_name, + role: user.role, + }, + } +} + +// --- Refresh --- + +export const refreshTokens = async ({ refreshToken, deviceInfo }) => { + const result = await rotateRefresh({ refreshToken, deviceInfo }) + let profile = null + if (result.user_type === UserType.CUSTOMER) { + profile = await getCustomerById(result.user_id) + } else if (result.user_type === UserType.MITRA) { + profile = await getMitraById(result.user_id) + } else if (result.user_type === UserType.CC_USER) { + const ccUser = await getCcUserById(result.user_id) + profile = ccUser ? { + id: ccUser.id, + email: ccUser.email, + display_name: ccUser.display_name, + role: ccUser.role, + } : null + } + return { tokens: result, profile } +} + +// --- Logout --- + +export const logout = async ({ sessionId }) => { + if (!sessionId) return + await revokeSession(sessionId) +} diff --git a/backend/src/services/cc-user.service.js b/backend/src/services/cc-user.service.js index 4e6d193..104dd1e 100644 --- a/backend/src/services/cc-user.service.js +++ b/backend/src/services/cc-user.service.js @@ -2,14 +2,16 @@ import { getDb } from '../db/client.js' const sql = getDb() -export const getCcUserByFirebaseUid = async (firebase_uid) => { + +export const getCcUserById = async (id) => { const [user] = await sql` SELECT u.id, u.email, u.display_name, u.created_at, + u.password_hash, u.failed_login_count, u.lockout_until, r.id as role_id, r.name as role_name, r.permissions FROM control_center_users u JOIN roles r ON r.id = u.role_id - WHERE u.firebase_uid = ${firebase_uid} + WHERE u.id = ${id} ` if (!user) return null return { @@ -17,20 +19,77 @@ export const getCcUserByFirebaseUid = async (firebase_uid) => { email: user.email, display_name: user.display_name, created_at: user.created_at, + password_hash: user.password_hash, + failed_login_count: user.failed_login_count, + lockout_until: user.lockout_until, role: { id: user.role_id, name: user.role_name, permissions: user.permissions }, } } -export const createCcUser = async ({ firebase_uid, email, display_name, role_id }) => { +export const getCcUserByEmail = async (email) => { const [user] = await sql` - INSERT INTO control_center_users (firebase_uid, email, display_name, role_id) - VALUES (${firebase_uid}, ${email}, ${display_name}, ${role_id}) + SELECT + u.id, u.email, u.display_name, u.created_at, + u.password_hash, u.failed_login_count, u.lockout_until, + r.id as role_id, r.name as role_name, r.permissions + FROM control_center_users u + JOIN roles r ON r.id = u.role_id + WHERE u.email = ${email} + ` + if (!user) return null + return { + id: user.id, + email: user.email, + display_name: user.display_name, + created_at: user.created_at, + password_hash: user.password_hash, + failed_login_count: user.failed_login_count, + lockout_until: user.lockout_until, + role: { id: user.role_id, name: user.role_name, permissions: user.permissions }, + } +} + +export const createCcUserWithPassword = async ({ email, display_name, role_id, password_hash }) => { + const [user] = await sql` + INSERT INTO control_center_users (email, display_name, role_id, password_hash) + VALUES (${email}, ${display_name}, ${role_id}, ${password_hash}) RETURNING id, email, display_name, role_id, created_at ` const [role] = await sql`SELECT id, name FROM roles WHERE id = ${role_id}` return { ...user, role } } +export const updateCcUserPasswordHash = async (id, password_hash) => { + await sql` + UPDATE control_center_users SET password_hash = ${password_hash} + WHERE id = ${id} + ` +} + +export const incrementCcUserFailedLogin = async (id, lockoutMinutes, maxAttempts) => { + // Atomic: increment counter; if reaches maxAttempts set lockout_until + const [row] = await sql` + UPDATE control_center_users + SET failed_login_count = failed_login_count + 1, + lockout_until = CASE + WHEN failed_login_count + 1 >= ${maxAttempts} + THEN NOW() + (${lockoutMinutes} || ' minutes')::interval + ELSE lockout_until + END + WHERE id = ${id} + RETURNING failed_login_count, lockout_until + ` + return row +} + +export const resetCcUserFailedLogin = async (id) => { + await sql` + UPDATE control_center_users + SET failed_login_count = 0, lockout_until = NULL + WHERE id = ${id} + ` +} + export const listCcUsers = async ({ page = 1, limit = 20 }) => { const offset = (page - 1) * limit const items = await sql` diff --git a/backend/src/services/customer.service.js b/backend/src/services/customer.service.js index 7aa6bd0..9f8b0c1 100644 --- a/backend/src/services/customer.service.js +++ b/backend/src/services/customer.service.js @@ -2,81 +2,6 @@ import { getDb } from '../db/client.js' const sql = getDb() -export const createAnonymousCustomer = async ({ display_name, firebase_uid }) => { - // Return existing customer if already linked to this Firebase UID - const [existing] = await sql` - SELECT id, display_name, is_anonymous, created_at - FROM customers WHERE firebase_uid = ${firebase_uid} - ` - if (existing) return existing - - const [customer] = await sql` - INSERT INTO customers (display_name, is_anonymous, firebase_uid) - VALUES (${display_name}, true, ${firebase_uid}) - RETURNING id, display_name, is_anonymous, created_at - ` - return customer -} - -export const linkCustomerAccount = async ({ customer_id, firebase_uid }) => { - const [existing] = await sql` - SELECT id, firebase_uid FROM customers WHERE id = ${customer_id} - ` - if (!existing) throw Object.assign(new Error('Customer not found'), { code: 'NOT_FOUND', statusCode: 404 }) - if (existing.firebase_uid) throw Object.assign(new Error('Account already linked'), { code: 'ALREADY_REGISTERED', statusCode: 409 }) - - // Also fetch phone from firebase_uid if exists in another customer record for uniqueness - const [firebaseLinked] = await sql` - SELECT id FROM customers WHERE firebase_uid = ${firebase_uid} - ` - if (firebaseLinked) throw Object.assign(new Error('Account already linked'), { code: 'ALREADY_REGISTERED', statusCode: 409 }) - - const [updated] = await sql` - UPDATE customers - SET firebase_uid = ${firebase_uid}, is_anonymous = false - WHERE id = ${customer_id} - RETURNING id, display_name, is_anonymous, phone, created_at - ` - return updated -} - -export const getCustomerByFirebaseUid = async (firebase_uid) => { - const [customer] = await sql` - SELECT id, display_name, is_anonymous, phone, created_at - FROM customers - WHERE firebase_uid = ${firebase_uid} - ` - return customer -} - -export const getOrCreateCustomer = async ({ firebase_uid, phone, display_name }) => { - // Return existing customer if already linked to this Firebase UID - const existing = await getCustomerByFirebaseUid(firebase_uid) - if (existing) return existing - - // Check if a customer with this phone already exists (re-login with new Firebase UID) - if (phone) { - const [byPhone] = await sql` - SELECT id, display_name, is_anonymous, phone, created_at - FROM customers WHERE phone = ${phone} - ` - if (byPhone) { - // Link the new Firebase UID to the existing phone-based customer - await sql`UPDATE customers SET firebase_uid = ${firebase_uid} WHERE id = ${byPhone.id}` - return { ...byPhone, firebase_uid } - } - } - - // Auto-create a registered (non-anonymous) customer for phone/social login - // display_name is null — user must set it on first login - const [customer] = await sql` - INSERT INTO customers (firebase_uid, phone, display_name, is_anonymous) - VALUES (${firebase_uid}, ${phone || null}, ${display_name || null}, false) - RETURNING id, display_name, is_anonymous, phone, created_at - ` - return customer -} - export const updateCustomerDisplayName = async (customerId, displayName) => { const [customer] = await sql` UPDATE customers SET display_name = ${displayName} @@ -85,3 +10,72 @@ export const updateCustomerDisplayName = async (customerId, displayName) => { ` return customer } + +// --- Phase 3.4: Self-Managed Auth --- + +const CUSTOMER_SELECT = sql`id, display_name, is_anonymous, phone, email, google_sub, apple_sub, created_at` + +export const getCustomerById = async (id) => { + const [row] = await sql`SELECT ${CUSTOMER_SELECT} FROM customers WHERE id = ${id}` + return row +} + +export const getCustomerByPhone = async (phone) => { + const [row] = await sql`SELECT ${CUSTOMER_SELECT} FROM customers WHERE phone = ${phone}` + return row +} + +export const getCustomerByGoogleSub = async (googleSub) => { + const [row] = await sql`SELECT ${CUSTOMER_SELECT} FROM customers WHERE google_sub = ${googleSub}` + return row +} + +export const getCustomerByAppleSub = async (appleSub) => { + const [row] = await sql`SELECT ${CUSTOMER_SELECT} FROM customers WHERE apple_sub = ${appleSub}` + return row +} + +export const createAnonymousCustomerV2 = async ({ display_name }) => { + const [row] = await sql` + INSERT INTO customers (display_name, is_anonymous) + VALUES (${display_name}, true) + RETURNING ${CUSTOMER_SELECT} + ` + return row +} + +export const createCustomerWithIdentity = async ({ + display_name, + phone = null, + email = null, + google_sub = null, + apple_sub = null, +}) => { + const [row] = await sql` + INSERT INTO customers (display_name, is_anonymous, phone, email, google_sub, apple_sub) + VALUES (${display_name}, false, ${phone}, ${email}, ${google_sub}, ${apple_sub}) + RETURNING ${CUSTOMER_SELECT} + ` + return row +} + +export const upgradeCustomerIdentity = async (customerId, { + phone, + email, + google_sub, + apple_sub, + display_name, +}) => { + const [row] = await sql` + UPDATE customers SET + is_anonymous = false, + phone = COALESCE(${phone ?? null}, phone), + email = COALESCE(${email ?? null}, email), + google_sub = COALESCE(${google_sub ?? null}, google_sub), + apple_sub = COALESCE(${apple_sub ?? null}, apple_sub), + display_name = COALESCE(${display_name ?? null}, display_name) + WHERE id = ${customerId} + RETURNING ${CUSTOMER_SELECT} + ` + return row +} diff --git a/backend/src/services/mitra.service.js b/backend/src/services/mitra.service.js index e5cb20c..eb2e10c 100644 --- a/backend/src/services/mitra.service.js +++ b/backend/src/services/mitra.service.js @@ -2,28 +2,22 @@ import { getDb } from '../db/client.js' const sql = getDb() -export const getMitraByFirebaseUid = async (firebase_uid) => { - const [mitra] = await sql` - SELECT id, display_name, phone, is_active, created_at - FROM mitras - WHERE firebase_uid = ${firebase_uid} - ` - return mitra -} - export const getMitraByPhone = async (phone) => { const [mitra] = await sql` - SELECT id, display_name, phone, is_active, firebase_uid, created_at + SELECT id, display_name, phone, is_active, created_at FROM mitras WHERE phone = ${phone} ` return mitra } -export const setMitraFirebaseUid = async (id, firebase_uid) => { - await sql` - UPDATE mitras SET firebase_uid = ${firebase_uid} WHERE id = ${id} +export const getMitraById = async (id) => { + const [mitra] = await sql` + SELECT id, display_name, phone, is_active, created_at + FROM mitras + WHERE id = ${id} ` + return mitra } export const createMitra = async ({ phone, display_name }) => {