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 / Redis
VALKEY_URL=redis://localhost:6379 VALKEY_URL=redis://localhost:6379
# Firebase # Control center origin (for CORS + refresh-cookie). Comma-separated list allowed.
FIREBASE_PROJECT_ID=your-firebase-project-id CC_ORIGIN=http://localhost:5173
FIREBASE_CLIENT_EMAIL=your-service-account@project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" # --- 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", "name": "halo-bestie-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.0.0",
"@fastify/sensible": "^6.0.0", "@fastify/sensible": "^6.0.0",
"@fastify/websocket": "^11.0.0", "@fastify/websocket": "^11.0.0",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"google-auth-library": "^9.15.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.2",
"pg": "^8.12.0", "pg": "^8.12.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -50,6 +55,26 @@
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT" "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": { "node_modules/@fastify/cors": {
"version": "11.2.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
@@ -457,6 +482,51 @@
"node": ">=8" "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": { "node_modules/@opentelemetry/api": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -628,6 +698,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
@@ -695,7 +770,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -716,6 +790,26 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/arrify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
@@ -772,6 +866,12 @@
"fastq": "^1.17.1" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -790,19 +890,41 @@
"url": "https://feross.org/support" "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", "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": { "node_modules/bignumber.js": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": "*" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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": ">= 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": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -867,6 +998,15 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -880,6 +1020,18 @@
"node": ">= 0.8" "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": { "node_modules/content-type": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
@@ -929,6 +1081,12 @@
"node": ">=0.4.0" "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": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -956,6 +1114,15 @@
"node": ">=6" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1008,8 +1175,7 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
@@ -1093,8 +1259,7 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/farmhash-modern": { "node_modules/farmhash-modern": {
"version": "1.1.0", "version": "1.1.0",
@@ -1353,6 +1518,36 @@
"node": ">= 0.6" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1370,12 +1565,32 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/gaxios": {
"version": "6.7.1", "version": "6.7.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"extend": "^3.0.2", "extend": "^3.0.2",
"https-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.1",
@@ -1396,7 +1611,6 @@
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
@@ -1406,7 +1620,6 @@
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"gaxios": "^6.1.1", "gaxios": "^6.1.1",
"google-logging-utils": "^0.0.2", "google-logging-utils": "^0.0.2",
@@ -1465,12 +1678,32 @@
"node": ">= 0.4" "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": { "node_modules/google-auth-library": {
"version": "9.15.1", "version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"base64-js": "^1.3.0", "base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11", "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", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"
} }
@@ -1549,7 +1781,6 @@
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"gaxios": "^6.0.0", "gaxios": "^6.0.0",
"jws": "^4.0.0" "jws": "^4.0.0"
@@ -1587,6 +1818,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
"debug": "4" "debug": "4"
@@ -1685,6 +1921,17 @@
"node": ">= 14" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "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", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1739,7 +1985,6 @@
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
}, },
@@ -1761,7 +2006,6 @@
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"bignumber.js": "^9.0.0" "bignumber.js": "^9.0.0"
} }
@@ -1988,6 +2232,30 @@
"lru-cache": "6.0.0" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2043,18 +2311,81 @@
"node": ">= 0.6" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"whatwg-url": "^5.0.0" "whatwg-url": "^5.0.0"
}, },
@@ -2079,6 +2410,43 @@
"node": ">= 6.13.0" "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": { "node_modules/object-hash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -2139,6 +2507,15 @@
"node": ">=14.0.0" "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": { "node_modules/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -2491,6 +2868,22 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2570,6 +2963,12 @@
"node": ">=10" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -2582,6 +2981,12 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "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": { "node_modules/sonic-boom": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "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", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.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", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@@ -2688,6 +3091,24 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/teeny-request": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
@@ -2777,8 +3198,7 @@
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
@@ -2863,8 +3283,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"optional": true
}, },
"node_modules/websocket-driver": { "node_modules/websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
@@ -2894,12 +3313,20 @@
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"tr46": "~0.0.3", "tr46": "~0.0.3",
"webidl-conversions": "^3.0.0" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "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" "db:seed": "node src/db/seed.js"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.0.0",
"@fastify/sensible": "^6.0.0", "@fastify/sensible": "^6.0.0",
"@fastify/websocket": "^11.0.0", "@fastify/websocket": "^11.0.0",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"firebase-admin": "^12.2.0", "firebase-admin": "^12.2.0",
"google-auth-library": "^9.15.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.2.2",
"pg": "^8.12.0", "pg": "^8.12.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -1,5 +1,6 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import cookie from '@fastify/cookie'
import sensible from '@fastify/sensible' import sensible from '@fastify/sensible'
import { mitraManagementRoutes } from './routes/internal/mitra.routes.js' import { mitraManagementRoutes } from './routes/internal/mitra.routes.js'
import { ccUserRoutes } from './routes/internal/cc-user.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 () => { export const buildInternalApp = async () => {
const app = Fastify({ logger: true }) 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) await app.register(sensible)
app.setErrorHandler(errorHandler) app.setErrorHandler(errorHandler)

View File

@@ -1,7 +1,7 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import sensible from '@fastify/sensible' 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 { clientAuthRoutes } from './routes/public/client.auth.routes.js'
import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js' import { mitraAuthRoutes } from './routes/public/mitra.auth.routes.js'
import { sharedConfigRoutes } from './routes/public/shared.config.routes.js' import { sharedConfigRoutes } from './routes/public/shared.config.routes.js'
@@ -20,7 +20,7 @@ export const buildPublicApp = async () => {
await registerWebSocketPlugin(app) await registerWebSocketPlugin(app)
app.setErrorHandler(errorHandler) 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(sharedConfigRoutes, { prefix: '/api/shared/config' })
app.register(sharedChatRoutes, { prefix: '/api/shared' }) app.register(sharedChatRoutes, { prefix: '/api/shared' })
app.register(clientAuthRoutes, { prefix: '/api/client/auth' }) app.register(clientAuthRoutes, { prefix: '/api/client/auth' })

View File

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

View File

@@ -1,13 +1,10 @@
import 'dotenv/config' import 'dotenv/config'
import admin from 'firebase-admin'
import { getDb } from './client.js' import { getDb } from './client.js'
import { initFirebase } from '../plugins/firebase.js' import { hashPassword } from '../services/password.service.js'
const sql = getDb() const sql = getDb()
const seed = async () => { const seed = async () => {
initFirebase()
// Create super_admin role // Create super_admin role
const [role] = await sql` const [role] = await sql`
INSERT INTO roles (name, permissions) INSERT INTO roles (name, permissions)
@@ -24,21 +21,14 @@ const seed = async () => {
RETURNING id RETURNING id
` `
// Create first super admin user in Firebase const email = process.env.ADMIN_EMAIL || process.env.SEED_ADMIN_EMAIL || 'admin@halobestie.com'
const email = process.env.SEED_ADMIN_EMAIL || 'admin@halobestie.com' const password = process.env.ADMIN_PASSWORD || process.env.SEED_ADMIN_PASSWORD || 'ChangeMe123!'
const password = process.env.SEED_ADMIN_PASSWORD || 'ChangeMe123!' const passwordHash = await hashPassword(password)
let firebaseUser
try {
firebaseUser = await admin.auth().getUserByEmail(email)
} catch {
firebaseUser = await admin.auth().createUser({ email, password, displayName: 'Super Admin' })
}
await sql` await sql`
INSERT INTO control_center_users (firebase_uid, email, display_name, role_id) INSERT INTO control_center_users (email, display_name, role_id, password_hash)
VALUES (${firebaseUser.uid}, ${email}, 'Super Admin', ${role.id}) VALUES (${email}, 'Super Admin', ${role.id}, ${passwordHash})
ON CONFLICT (email) DO NOTHING ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash
` `
console.log(`Seed complete. Admin: ${email}`) 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. * Fastify preHandler — verifies our JWT access token and attaches claims to request.auth.
* Usage: add as preHandler on any route that requires authentication. *
* 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) => { export const authenticate = async (request, reply) => {
const authHeader = request.headers.authorization const authHeader = request.headers.authorization
@@ -15,18 +20,25 @@ export const authenticate = async (request, reply) => {
const token = authHeader.slice(7) const token = authHeader.slice(7)
try { try {
request.firebaseUser = await verifyFirebaseToken(token) const claims = verifyAccessToken(token)
} catch (err) { if (!claims.userId || !claims.userType || !claims.sessionId) {
console.error('Auth failed:', err.code || err.message, '| token preview:', token.substring(0, 20) + '...')
return reply.code(401).send({ return reply.code(401).send({
success: false, 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') * Usage: requirePermission('mitra', 'create')
*/ */
export const requirePermission = (resource, action) => { export const requirePermission = (resource, action) => {

View File

@@ -7,6 +7,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
let initialized = false 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 = () => { export const initFirebase = () => {
if (initialized) return if (initialized) return
@@ -19,7 +23,3 @@ export const initFirebase = () => {
}) })
initialized = true initialized = true
} }
export const verifyFirebaseToken = async (token) => {
return admin.auth().verifyIdToken(token)
}

View File

@@ -1,7 +1,5 @@
import websocket from '@fastify/websocket' import websocket from '@fastify/websocket'
import { verifyFirebaseToken } from './firebase.js' import { verifyAccessToken } from '../services/token.service.js'
import { getCustomerByFirebaseUid } from '../services/customer.service.js'
import { getMitraByFirebaseUid } from '../services/mitra.service.js'
import { subscribe, publish } from './valkey.js' import { subscribe, publish } from './valkey.js'
import { UserType, WsMessage } from '../constants.js' import { UserType, WsMessage } from '../constants.js'
@@ -64,18 +62,15 @@ export const registerWebSocketRoute = (app) => {
// Handle auth message // Handle auth message
if (msg.type === WsMessage.AUTH) { if (msg.type === WsMessage.AUTH) {
try { try {
const decoded = await verifyFirebaseToken(msg.token) const claims = verifyAccessToken(msg.token)
const customer = await getCustomerByFirebaseUid(decoded.uid) if (claims.userType !== UserType.CUSTOMER && claims.userType !== UserType.MITRA) {
const mitra = customer ? null : await getMitraByFirebaseUid(decoded.uid) send({ type: WsMessage.ERROR, message: 'Unsupported user type for websocket' })
if (!customer && !mitra) {
send({ type: WsMessage.ERROR, message: 'Account not found' })
socket.close() socket.close()
return return
} }
const userType = customer ? UserType.CUSTOMER : UserType.MITRA const userType = claims.userType
const userId = customer ? customer.id : mitra.id const userId = claims.userId
const sessionId = msg.session_id const sessionId = msg.session_id
authenticatedUser = { type: userType, id: userId, sessionId } authenticatedUser = { type: userType, id: userId, sessionId }

View File

@@ -1,17 +1,127 @@
import { authenticate } from '../../plugins/auth.js' 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) => { export const internalAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => { app.post('/login', async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) const { email, password } = request.body || {}
if (!user) { 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({ return reply.code(403).send({
success: false, 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 const user = await getCcUserById(request.auth.userId)
request.ccUser = user if (!user) {
return reply.send({ success: true, data: 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 { 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 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' } }) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = 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) => { export const ccUserRoutes = async (app) => {
// Create CC user (with initial password)
app.post('/', { app.post('/', {
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'create')], preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'create')],
}, async (request, reply) => { }, async (request, reply) => {
const { email, display_name, role_id } = request.body ?? {} const { email, display_name, role_id, password } = request.body ?? {}
if (!email || !display_name || !role_id) { if (!email || !display_name || !role_id || !password) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'email, display_name, and role_id are required' } }) return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'email, display_name, role_id, and password are required' },
})
} }
try {
// Create Firebase user with temporary password — admin will share credentials verbally validatePasswordComplexity(password)
const { initFirebase } = await import('../../plugins/firebase.js') } catch (err) {
const admin = (await import('firebase-admin')).default return sendValidation(reply, err)
initFirebase() }
const passwordHash = await hashPassword(password)
const tempPassword = Math.random().toString(36).slice(-10) + 'A1!' const user = await createCcUserWithPassword({ email, display_name, role_id, password_hash: passwordHash })
const firebaseUser = await admin.auth().createUser({ email, password: tempPassword })
const user = await createCcUser({ firebase_uid: firebaseUser.uid, email, display_name, role_id })
return reply.code(201).send({ success: true, data: user }) return reply.code(201).send({ success: true, data: user })
}) })
// List CC users
app.get('/', { app.get('/', {
preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('control_center_users', 'read')],
}, async (request, reply) => { }, async (request, reply) => {
@@ -35,4 +56,53 @@ export const ccUserRoutes = async (app) => {
const result = await listCcUsers({ page: Number(page), limit: Number(limit) }) const result = await listCcUsers({ page: Number(page), limit: Number(limit) })
return reply.send({ success: true, data: result }) 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 { 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 { import {
getAnonymityConfig, setAnonymityConfig, getAnonymityConfig, setAnonymityConfig,
getMaxCustomersPerMitra, setMaxCustomersPerMitra, getMaxCustomersPerMitra, setMaxCustomersPerMitra,
@@ -11,7 +12,10 @@ import {
} from '../../services/config.service.js' } from '../../services/config.service.js'
const attachCcUser = async (request, reply) => { 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' } }) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user request.ccUser = user
} }

View File

@@ -1,13 +1,14 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' 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 { getMitraActivityLog, getMitraActivitySummary } from '../../services/mitra-activity.service.js'
import { UserType } from '../../constants.js'
const attachCcUser = async (request, reply) => { const attachCcUser = async (request, reply) => {
const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) if (request.auth?.userType !== UserType.CC_USER) {
if (!user) return reply.code(403).send({ return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
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 request.ccUser = user
} }

View File

@@ -1,10 +1,14 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' 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 { createMitra, listMitras, updateMitraStatus } from '../../services/mitra.service.js'
import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js' import { getOnlineMitras, getOnlineLogs } from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js'
const attachCcUser = async (request, reply) => { 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' } }) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user request.ccUser = user
} }

View File

@@ -1,9 +1,13 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' 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 { listRoles } from '../../services/roles.service.js'
import { UserType } from '../../constants.js'
const attachCcUser = async (request, reply) => { 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' } }) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user request.ccUser = user
} }

View File

@@ -1,11 +1,15 @@
import { authenticate, requirePermission } from '../../plugins/auth.js' 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 { listSessions, getSessionById, rerouteSession } from '../../services/session.service.js'
import { getSessionSensitivityLog } from '../../services/sensitivity.service.js' import { getSessionSensitivityLog } from '../../services/sensitivity.service.js'
import { getDashboardStats } from '../../services/dashboard.service.js' import { getDashboardStats } from '../../services/dashboard.service.js'
import { UserType } from '../../constants.js'
const attachCcUser = async (request, reply) => { 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' } }) if (!user) return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
request.ccUser = user request.ccUser = user
} }

View File

@@ -1,25 +1,134 @@
import { authenticate } from '../../plugins/auth.js' 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) => { export const clientAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => { // --- Phone OTP ---
const { uid, phone_number, name } = request.firebaseUser
const customer = await getOrCreateCustomer({ app.post('/otp/request', async (request, reply) => {
firebase_uid: uid, const { phone, channel } = request.body || {}
phone: phone_number || null, try {
display_name: name || null, 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) => { app.post('/otp/verify', async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) 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) { if (!customer) {
return reply.code(404).send({ return reply.code(404).send({
success: false, success: false,
error: { code: 'NOT_FOUND', message: 'Customer account not found' }, 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 || {} const { display_name } = request.body || {}
if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) { if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) {
return reply.code(422).send({ return reply.code(422).send({
@@ -27,7 +136,7 @@ export const clientAuthRoutes = async (app) => {
error: { code: 'VALIDATION_ERROR', message: 'display_name is required' }, 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 }) return reply.send({ success: true, data: updated })
}) })
} }

View File

@@ -1,13 +1,19 @@
import { authenticate } from '../../plugins/auth.js' 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 { createPairingRequest, cancelPairingRequest } from '../../services/pairing.service.js'
import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js' import { getActiveSessionByCustomer, getActiveSessionByCustomerWithUnread, endSession, getCustomerHistory } from '../../services/session.service.js'
import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js' import { getPricingForCustomer, isValidTier, isCustomerEligibleForFreeTrial, getFreeTrial } from '../../services/pricing.service.js'
import { requestExtension } from '../../services/extension.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 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) { if (!customer) {
return reply.code(404).send({ return reply.code(404).send({
success: false, 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 { 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) => { export const mitraAuthRoutes = async (app) => {
app.post('/verify', { preHandler: authenticate }, async (request, reply) => { app.post('/otp/request', async (request, reply) => {
const { uid, phone_number } = request.firebaseUser const { phone, channel } = request.body || {}
try {
// First try lookup by firebase_uid (returning user) const result = await requestOtp({
let mitra = await getMitraByFirebaseUid(uid) phone,
userType: UserType.MITRA,
// First-time login: link firebase_uid to mitra record via phone number ipAddress: request.ip,
if (!mitra && phone_number) { channel,
mitra = await getMitraByPhone(phone_number) })
if (mitra) { return reply.send({ success: true, data: result })
await setMitraFirebaseUid(mitra.id, uid) } catch (err) {
} return sendAuthError(reply, err)
} }
})
if (!mitra) { app.post('/otp/verify', async (request, reply) => {
return reply.code(404).send({ 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, 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' },
}) })
} }
const { tokens, profile } = await completeMitraPhoneSignIn({
if (!mitra.is_active) { phone,
deviceInfo: extractDeviceInfo(request),
})
if (!profile.is_active) {
return reply.code(403).send({ return reply.code(403).send({
success: false, success: false,
error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' }, error: { code: 'ACCOUNT_INACTIVE', message: 'Account is inactive. Contact your administrator.' },
}) })
} }
return reply.send({ success: true, data: { ...tokens, profile } })
return reply.send({ } catch (err) {
success: true, return sendAuthError(reply, err)
data: { }
id: mitra.id,
display_name: mitra.display_name,
phone: mitra.phone,
is_active: mitra.is_active,
created_at: mitra.created_at,
},
}) })
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 { 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 { acceptPairingRequest, declinePairingRequest, getSessionStatus, getPendingRequestsForMitra } from '../../services/pairing.service.js'
import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js' import { getActiveSessionsByMitra, getActiveSessionsByMitraWithUnread, endSession, getMitraHistory } from '../../services/session.service.js'
import { respondToExtension } from '../../services/extension.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 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) { if (!mitra) {
return reply.code(404).send({ return reply.code(404).send({
success: false, success: false,

View File

@@ -1,11 +1,17 @@
import { authenticate } from '../../plugins/auth.js' 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 * as mitraStatusService from '../../services/mitra-status.service.js'
import { UserType } from '../../constants.js'
export const mitraStatusRoutes = async (app) => { export const mitraStatusRoutes = async (app) => {
// Resolve mitra from Firebase token
const resolveMitra = async (request, reply) => { 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) { if (!mitra) {
return reply.code(404).send({ return reply.code(404).send({
success: false, 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 { 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 { getMessages } from '../../services/chat.service.js'
import { getSessionClosures } from '../../services/closure.service.js' import { getSessionClosures } from '../../services/closure.service.js'
import { registerDeviceToken } from '../../services/notification.service.js' import { registerDeviceToken } from '../../services/notification.service.js'
@@ -11,23 +9,15 @@ import { TopicSensitivity, UserType } from '../../constants.js'
const sql = getDb() const sql = getDb()
const resolveUser = async (request, reply) => { const resolveUser = async (request, reply) => {
const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) if (request.auth?.userType !== UserType.CUSTOMER && request.auth?.userType !== UserType.MITRA) {
if (customer) { return reply.code(403).send({
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({
success: false, 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 // Verify session belongs to the authenticated user
const verifySessionOwnership = async (request, reply) => { 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() const sql = getDb()
export const getCcUserByFirebaseUid = async (firebase_uid) => {
export const getCcUserById = async (id) => {
const [user] = await sql` const [user] = await sql`
SELECT SELECT
u.id, u.email, u.display_name, u.created_at, 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 r.id as role_id, r.name as role_name, r.permissions
FROM control_center_users u FROM control_center_users u
JOIN roles r ON r.id = u.role_id JOIN roles r ON r.id = u.role_id
WHERE u.firebase_uid = ${firebase_uid} WHERE u.id = ${id}
` `
if (!user) return null if (!user) return null
return { return {
@@ -17,20 +19,77 @@ export const getCcUserByFirebaseUid = async (firebase_uid) => {
email: user.email, email: user.email,
display_name: user.display_name, display_name: user.display_name,
created_at: user.created_at, 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 }, 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` const [user] = await sql`
INSERT INTO control_center_users (firebase_uid, email, display_name, role_id) SELECT
VALUES (${firebase_uid}, ${email}, ${display_name}, ${role_id}) 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 RETURNING id, email, display_name, role_id, created_at
` `
const [role] = await sql`SELECT id, name FROM roles WHERE id = ${role_id}` const [role] = await sql`SELECT id, name FROM roles WHERE id = ${role_id}`
return { ...user, role } 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 }) => { export const listCcUsers = async ({ page = 1, limit = 20 }) => {
const offset = (page - 1) * limit const offset = (page - 1) * limit
const items = await sql` const items = await sql`

View File

@@ -2,81 +2,6 @@ import { getDb } from '../db/client.js'
const sql = getDb() 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) => { export const updateCustomerDisplayName = async (customerId, displayName) => {
const [customer] = await sql` const [customer] = await sql`
UPDATE customers SET display_name = ${displayName} UPDATE customers SET display_name = ${displayName}
@@ -85,3 +10,72 @@ export const updateCustomerDisplayName = async (customerId, displayName) => {
` `
return customer 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() 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) => { export const getMitraByPhone = async (phone) => {
const [mitra] = await sql` 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 FROM mitras
WHERE phone = ${phone} WHERE phone = ${phone}
` `
return mitra return mitra
} }
export const setMitraFirebaseUid = async (id, firebase_uid) => { export const getMitraById = async (id) => {
await sql` const [mitra] = await sql`
UPDATE mitras SET firebase_uid = ${firebase_uid} WHERE id = ${id} SELECT id, display_name, phone, is_active, created_at
FROM mitras
WHERE id = ${id}
` `
return mitra
} }
export const createMitra = async ({ phone, display_name }) => { export const createMitra = async ({ phone, display_name }) => {