Phase 3.4: backend self-managed auth cutover

All backend auth now goes through our own token service — Firebase Auth
dependency is fully removed from auth paths. FCM (firebase-admin messaging)
is still used for push.

Schema:
- auth_sessions (multi-device refresh tokens, bcrypt-hashed)
- otp_requests (Fazpass reference + rate-limit history)
- customers.email + google_sub + apple_sub (social identity)
- control_center_users.password_hash + failed_login_count + lockout_until
- firebase_uid columns made nullable (drop in later cleanup migration)
- 6 new app_config keys for OTP + CC lockout tuning

Services:
- password.service.js — bcrypt cost 12 + complexity (min 8, digit + upper +
  lower)
- token.service.js — JWT HS256 access (1h) + opaque refresh (30d, bcrypt-
  hashed, rotated on use); session_id claim pre-wires future Valkey-based
  instant revocation; revokeSession + revokeAllSessionsForUser helpers
- social-identity.service.js — Google via google-auth-library, Apple via
  jwks-rsa + jsonwebtoken
- otp.service.js — Fazpass stub (generates locally, logs the code) clearly
  marked for replacement once real API docs arrive; rate-limit + resend
  cooldown + verify-attempts all configurable via app_config
- auth.service.js — orchestrator: signInAnonymous, completeCustomer/Mitra-
  PhoneSignIn, signInWithGoogle, signInWithApple, signInCcUser, refresh,
  logout; reject-on-existing for identity conflicts
- cc-user.service.js — email+password helpers + lockout counters

Routes & middleware:
- authenticate middleware now verifies our JWT and attaches
  request.auth = { userType, userId, sessionId }
- WebSocket handshake verifies our JWT (no more Firebase lookup)
- All existing routes updated to use request.auth.userId instead of
  request.firebaseUser.uid
- New public routes:
    /api/shared/auth/anonymous /refresh /logout
    /api/client/auth/otp/request /otp/verify /google /apple /me /profile
    /api/mitra/auth/otp/request /otp/verify /me
- New internal routes:
    /internal/auth/login /refresh /logout /me (httpOnly cookie refresh)
    /internal/control-center-users (accepts plain password, bcrypt-hashed)
    /internal/control-center-users/me/password (self-service change)
    /internal/control-center-users/:id/password (admin forced reset)
- Deleted legacy customer.routes.js (anonymous + link handled by auth now)
- app.internal.js: @fastify/cookie + CORS credentials for CC httpOnly cookie

Config:
- AUTH_JWT_SECRET + ACCESS_TOKEN_TTL_SECONDS + REFRESH_TOKEN_TTL_DAYS env
- FAZPASS_* env vars (TBD until real API docs)
- GOOGLE_OAUTH_CLIENT_IDS, APPLE_SERVICES_ID/TEAM_ID/KEY_ID/PRIVATE_KEY
- ADMIN_EMAIL + ADMIN_PASSWORD for seed
- CC_ORIGIN for internal-app CORS origin allowlist

Dependencies:
- Added: bcrypt, jsonwebtoken, jwks-rsa, google-auth-library, @fastify/cookie
- Kept: firebase-admin (messaging only)

Still outstanding: Fazpass API integration (stub in place), Apple Developer
prereqs for end-to-end iOS testing, client_app/mitra_app/control_center auth
flow rewrites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 11:43:25 +08:00
parent 780cade3db
commit f860ab6c85
29 changed files with 1423 additions and 310 deletions

View File

@@ -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=

View File

@@ -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",

View File

@@ -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"

View File

@@ -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)

View File

@@ -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' })

View File

@@ -2,6 +2,7 @@
export const UserType = Object.freeze({
CUSTOMER: 'customer',
MITRA: 'mitra',
CC_USER: 'cc_user',
})
// Chat session statuses

View File

@@ -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}`)

View File

@@ -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 <session_id> 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)
} catch (err) {
console.error('Auth failed:', err.code || err.message, '| token preview:', token.substring(0, 20) + '...')
const claims = verifyAccessToken(token)
if (!claims.userId || !claims.userType || !claims.sessionId) {
return reply.code(401).send({
success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' },
error: { code: 'UNAUTHORIZED', message: 'Invalid token claims' },
})
}
request.auth = claims
} catch (err) {
return reply.code(err.statusCode || 401).send({
success: false,
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) => {

View File

@@ -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)
}

View File

@@ -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 }

View File

@@ -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'
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('/verify', { preHandler: authenticate }, async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid)
if (!user) {
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: 'Not a control center user' },
error: { code: 'FORBIDDEN', message: 'Control center account required' },
})
}
// Attach to request for downstream permission checks
request.ccUser = user
return reply.send({ success: true, data: user })
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,
},
})
})
}

View File

@@ -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 })
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
// --- 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: customer })
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 })
})
}

View File

@@ -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,

View File

@@ -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 })
})
}

View File

@@ -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)
}
})
if (!mitra) {
return reply.code(404).send({
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: 'ACCOUNT_NOT_FOUND', message: 'Account not found. Contact your administrator.' },
error: { code: 'WRONG_FLOW', message: 'This OTP was issued for a different user type' },
})
}
if (!mitra.is_active) {
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: {
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: { ...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: 'NOT_FOUND', message: 'Mitra account not found' },
})
}
return reply.send({ success: true, data: mitra })
})
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 })
})
}

View File

@@ -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,23 +9,15 @@ 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
}
const mitra = await getMitraByFirebaseUid(request.firebaseUser.uid)
if (mitra) {
request.userType = UserType.MITRA
request.userId = mitra.id
return
}
return reply.code(404).send({
if (request.auth?.userType !== UserType.CUSTOMER && request.auth?.userType !== UserType.MITRA) {
return reply.code(403).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Account not found' },
error: { code: 'FORBIDDEN', message: 'Customer or mitra account required' },
})
}
request.userType = request.auth.userType
request.userId = request.auth.userId
}
// Verify session belongs to the authenticated user
const verifySessionOwnership = async (request, reply) => {

View File

@@ -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)
}

View File

@@ -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`

View File

@@ -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
}

View File

@@ -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 }) => {