Phase 3.3: topic sensitivity + Phase 3.4: auth foundation

Phase 3.3 — Session Topic Sensitivity (complete):
- Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service
  (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic,
  topic carried in pairing + extension WS payloads, CC filter + sensitive stats
  + per-mitra sensitive columns on activity page
- client_app: TopicSelectionBottomSheet before pricing, topic flows through
  pairing request, silent WS handler for session_topic_updated
- mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider,
  overlay badge + yellow accent, chat screen app-bar toggle with configurable
  confirmation + latch, extension card shows current flag, history + transcript
  yellow theme
- control_center: Sensitivitas Topik settings section, topic filter + column
  with inline audit log, sensitive stats dashboard card, mitra activity
  sensitive columns with QC flag

Phase 3.4 — Self-Managed Auth (foundation only):
- Migration: auth_sessions + otp_requests tables, social identity columns on
  customers, password_hash + lockout on control_center_users, OTP + CC lockout
  app_config keys
- New services: password (bcrypt + complexity), token (JWT HS256 + refresh
  rotation, session_id claim pre-wires future Valkey revocation),
  social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD)
- Constants: AuthProvider + OtpChannel
- Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation
  still pending (next chunk); Fazpass docs + Apple Developer setup still
  required before E2E testing

Docs:
- requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md
- requirement/phase3.4.md, phase3.4-plan.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 10:15:12 +08:00
parent 97d50a8e08
commit 780cade3db
44 changed files with 3834 additions and 103 deletions

View File

@@ -128,3 +128,97 @@ export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) =>
}
return getEarlyEndConfig()
}
// --- Phase 3.3: Session Topic Sensitivity ---
export const getSensitivityConfig = async () => {
const [confirmRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flip_confirmation_enabled'`
const [latchRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flag_one_way_latch'`
return {
flip_confirmation_enabled: confirmRow?.value?.value ?? true,
one_way_latch: latchRow?.value?.value ?? false,
}
}
export const setSensitivityConfig = async ({ flip_confirmation_enabled, one_way_latch }) => {
if (flip_confirmation_enabled !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flip_confirmation_enabled', ${sql.json({ value: flip_confirmation_enabled })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (one_way_latch !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('sensitive_flag_one_way_latch', ${sql.json({ value: one_way_latch })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getSensitivityConfig()
}
// --- Phase 3.4: Self-Managed Auth ---
export const getOtpRateLimits = async () => {
const [phoneRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_phone_per_hour'`
const [ipRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_ip_per_hour'`
const [resendRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_resend_cooldown_seconds'`
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_verify_max_attempts'`
return {
max_per_phone_per_hour: phoneRow?.value?.value ?? 3,
max_per_ip_per_hour: ipRow?.value?.value ?? 10,
resend_cooldown_seconds: resendRow?.value?.value ?? 60,
verify_max_attempts: attemptsRow?.value?.value ?? 5,
}
}
export const setOtpRateLimits = async ({
max_per_phone_per_hour,
max_per_ip_per_hour,
resend_cooldown_seconds,
verify_max_attempts,
}) => {
const pairs = [
['otp_max_per_phone_per_hour', max_per_phone_per_hour],
['otp_max_per_ip_per_hour', max_per_ip_per_hour],
['otp_resend_cooldown_seconds', resend_cooldown_seconds],
['otp_verify_max_attempts', verify_max_attempts],
]
for (const [key, value] of pairs) {
if (value === undefined) continue
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES (${key}, ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getOtpRateLimits()
}
export const getCcLoginLockoutConfig = async () => {
const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_max_attempts'`
const [minutesRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_lockout_minutes'`
return {
max_attempts: attemptsRow?.value?.value ?? 5,
lockout_minutes: minutesRow?.value?.value ?? 15,
}
}
export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) => {
if (max_attempts !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_max_attempts', ${sql.json({ value: max_attempts })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
if (lockout_minutes !== undefined) {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('cc_login_lockout_minutes', ${sql.json({ value: lockout_minutes })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
}
return getCcLoginLockoutConfig()
}

View File

@@ -1,13 +1,23 @@
import { getDb } from '../db/client.js'
import { SessionStatus } from '../constants.js'
import { SessionStatus, TopicSensitivity } from '../constants.js'
const sql = getDb()
export const getDashboardStats = async () => {
const [[{ active_chats }], [{ online_mitras }], [{ pending_requests }]] = await Promise.all([
const [
[{ active_chats }],
[{ online_mitras }],
[{ pending_requests }],
[{ sensitive_total }],
[{ sensitive_last_30d_total }],
[{ sensitive_last_30d_sensitive }],
] = await Promise.all([
sql`SELECT COUNT(*) AS active_chats FROM chat_sessions WHERE status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT})`,
sql`SELECT COUNT(*) AS online_mitras FROM mitra_online_status WHERE is_online = true`,
sql`SELECT COUNT(*) AS pending_requests FROM chat_sessions WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})`,
sql`SELECT COUNT(*) AS sensitive_total FROM chat_sessions WHERE topic_sensitivity = ${TopicSensitivity.SENSITIVE}`,
sql`SELECT COUNT(*) AS sensitive_last_30d_total FROM chat_sessions WHERE created_at >= NOW() - INTERVAL '30 days'`,
sql`SELECT COUNT(*) AS sensitive_last_30d_sensitive FROM chat_sessions WHERE created_at >= NOW() - INTERVAL '30 days' AND topic_sensitivity = ${TopicSensitivity.SENSITIVE}`,
])
const customersPerMitra = await sql`
@@ -20,10 +30,19 @@ export const getDashboardStats = async () => {
ORDER BY active_session_count DESC
`
const last30dTotal = Number(sensitive_last_30d_total)
const last30dSensitive = Number(sensitive_last_30d_sensitive)
return {
active_chats: Number(active_chats),
online_mitras: Number(online_mitras),
pending_requests: Number(pending_requests),
customers_per_mitra: customersPerMitra,
sensitive: {
total: Number(sensitive_total),
last_30d_total: last30dTotal,
last_30d_sensitive: last30dSensitive,
last_30d_percent: last30dTotal > 0 ? Math.round((last30dSensitive / last30dTotal) * 1000) / 10 : 0,
},
}
}

View File

@@ -17,7 +17,7 @@ const getExtensionTimeout = async () => {
export const requestExtension = async (sessionId, customerId, { duration_minutes, price }) => {
// Verify session belongs to customer and is in an extendable state
const [session] = await sql`
SELECT id, customer_id, mitra_id, status FROM chat_sessions
SELECT id, customer_id, mitra_id, status, topic_sensitivity FROM chat_sessions
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING})
`
@@ -35,13 +35,14 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
// Pause the session
await sql`UPDATE chat_sessions SET status = ${SessionStatus.EXTENDING} WHERE id = ${sessionId}`
// Notify mitra
// Notify mitra — include current topic sensitivity so UI can highlight
sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.EXTENSION_REQUEST,
extension_id: extension.id,
session_id: sessionId,
duration_minutes,
price,
topic_sensitivity: session.topic_sensitivity,
})
// Notify customer that chat is paused

View File

@@ -1,4 +1,5 @@
import { getDb } from '../db/client.js'
import { TopicSensitivity } from '../constants.js'
const sql = getDb()
@@ -18,12 +19,14 @@ export const getMitraActivityLog = async ({ mitra_id, date_from, date_to, page =
SELECT crn.id, crn.session_id, crn.mitra_id, crn.response,
crn.notified_at, crn.responded_at, crn.active_session_count,
m.display_name AS mitra_display_name,
cs.topic_sensitivity,
CASE WHEN crn.responded_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))::int
ELSE NULL
END AS response_time_seconds
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
LEFT JOIN chat_sessions cs ON cs.id = crn.session_id
${where}
ORDER BY crn.notified_at DESC
LIMIT ${limit} OFFSET ${offset}
@@ -63,9 +66,17 @@ export const getMitraActivitySummary = async ({ mitra_id, date_from, date_to } =
THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))
ELSE NULL
END
)::numeric(10,1) AS avg_response_time_seconds
)::numeric(10,1) AS avg_response_time_seconds,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE})::int AS sensitive_total,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'accepted')::int AS sensitive_accepted,
COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'declined')::int AS sensitive_rejected,
ROUND(
100.0 * COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE} AND crn.response = 'accepted')
/ NULLIF(COUNT(*) FILTER (WHERE cs.topic_sensitivity = ${TopicSensitivity.SENSITIVE}), 0), 1
) AS sensitive_acceptance_rate
FROM chat_request_notifications crn
INNER JOIN mitras m ON m.id = crn.mitra_id
LEFT JOIN chat_sessions cs ON cs.id = crn.session_id
${where}
GROUP BY crn.mitra_id, m.display_name
ORDER BY acceptance_rate DESC NULLS LAST

View File

@@ -0,0 +1,170 @@
import crypto from 'node:crypto'
import { getDb } from '../db/client.js'
import { getOtpRateLimits } from './config.service.js'
import { OtpChannel, UserType } from '../constants.js'
const sql = getDb()
const OTP_TTL_MINUTES = 5
// -------------------------------------------------------------------
// ⚠ Fazpass integration — STUB until real API docs are obtained.
//
// In production, Fazpass is the source of truth for the OTP code.
// We will only ever handle a reference ID (string) returned by Fazpass,
// never the raw code. For now, we generate a 6-digit code locally and
// store its bcrypt hash in the metadata field of otp_requests via
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
// round-trip without schema changes.
//
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
// with real HTTP calls and drop the local code generation.
// -------------------------------------------------------------------
const generate6DigitCode = () => {
// Avoid Math.random for OTP generation — use crypto.randomInt
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
}
const fazpassSendStub = async ({ phone, channel }) => {
const reference = `stub_${crypto.randomUUID()}`
const code = generate6DigitCode()
// Log the code so developers can read it during dev testing.
// eslint-disable-next-line no-console
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
return { reference, channel_used: channel, code } // `code` only present in stub
}
const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
return { valid: code === expectedCode }
}
// -------------------------------------------------------------------
export class OtpError extends Error {
constructor(message, code, statusCode) {
super(message)
this.code = code
this.statusCode = statusCode
}
}
const checkRateLimits = async ({ phone, ipAddress, limits }) => {
// Resend cooldown
const [lastRow] = await sql`
SELECT created_at FROM otp_requests
WHERE phone = ${phone}
ORDER BY created_at DESC LIMIT 1
`
if (lastRow) {
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
if (elapsed < limits.resend_cooldown_seconds) {
throw new OtpError(
`Please wait ${Math.ceil(limits.resend_cooldown_seconds - elapsed)}s before requesting another OTP`,
'OTP_COOLDOWN', 429,
)
}
}
// Per-phone hourly limit
const [{ phone_count }] = await sql`
SELECT COUNT(*)::int AS phone_count FROM otp_requests
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (phone_count >= limits.max_per_phone_per_hour) {
throw new OtpError('Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429)
}
// Per-IP hourly limit (only if ip provided)
if (ipAddress) {
const [{ ip_count }] = await sql`
SELECT COUNT(*)::int AS ip_count FROM otp_requests
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
`
if (ip_count >= limits.max_per_ip_per_hour) {
throw new OtpError('Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429)
}
}
}
/**
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
* Does NOT return the code to the caller.
*/
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => {
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
}
if (userType !== UserType.CUSTOMER && userType !== UserType.MITRA) {
throw new OtpError('Invalid user type', 'USER_TYPE_INVALID', 400)
}
const limits = await getOtpRateLimits()
await checkRateLimits({ phone, ipAddress, limits })
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
// Store the reference. In stub mode, we also store the expected code appended
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
// the code; Fazpass itself holds it. This line is the main place to change
// when switching to real Fazpass.
const storedReference = code ? `${reference}:${code}` : reference
const [row] = await sql`
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
VALUES (
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
)
RETURNING id, expires_at
`
return {
otp_request_id: row.id,
channel_used,
expires_at: row.expires_at,
}
}
/**
* Verify an OTP code. Returns { phone, user_type } on success.
* Throws OtpError on failure.
*/
export const verifyOtp = async ({ otpRequestId, code }) => {
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
}
const limits = await getOtpRateLimits()
const [row] = await sql`
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
FROM otp_requests
WHERE id = ${otpRequestId}
`
if (!row) {
throw new OtpError('OTP request not found', 'OTP_NOT_FOUND', 404)
}
if (row.used_at) {
throw new OtpError('OTP already used', 'OTP_USED', 409)
}
if (new Date(row.expires_at) <= new Date()) {
throw new OtpError('OTP expired', 'OTP_EXPIRED', 410)
}
if (row.attempts >= limits.verify_max_attempts) {
throw new OtpError('Too many verification attempts; request a new OTP', 'OTP_ATTEMPTS_EXCEEDED', 429)
}
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
// Stub: fazpass_reference is stored as "<ref>:<code>"
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
if (!valid) {
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
}
await sql`UPDATE otp_requests SET used_at = NOW() WHERE id = ${otpRequestId}`
return { phone: row.phone, user_type: row.user_type }
}

View File

@@ -4,7 +4,7 @@ import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { startSessionTimer } from './session-timer.service.js'
import { startSessionListener } from './chat-handler.service.js'
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage } from '../constants.js'
import { UserType, SessionStatus, NotificationResponse, TransactionType, WsMessage, TopicSensitivity } from '../constants.js'
const sql = getDb()
@@ -63,7 +63,7 @@ export const findAvailableMitras = async () => {
return mitras
}
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial } = {}) => {
export const createPairingRequest = async (customerId, { duration_minutes, price, is_free_trial, topic_sensitivity } = {}) => {
// Check for existing active session or request
const [existing] = await sql`
SELECT id, status FROM chat_sessions
@@ -83,11 +83,15 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
})
}
// Create session with duration/price
const resolvedTopic = topic_sensitivity === TopicSensitivity.SENSITIVE
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
// Create session with duration/price/topic
const [session] = await sql`
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial)
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, created_at
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity)
VALUES (${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${duration_minutes || null}, ${price || 0}, ${is_free_trial || false}, ${resolvedTopic})
RETURNING id, customer_id, status, duration_minutes, price, is_free_trial, topic_sensitivity, created_at
`
// Create notifications for all available mitras
@@ -108,6 +112,7 @@ export const createPairingRequest = async (customerId, { duration_minutes, price
created_at: session.created_at,
duration_minutes: session.duration_minutes,
is_free_trial: session.is_free_trial,
topic_sensitivity: session.topic_sensitivity,
})
}
@@ -309,7 +314,7 @@ export const expirePairingRequest = async (sessionId) => {
export const getPendingRequestsForMitra = async (mitraId) => {
const rows = await sql`
SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.created_at
SELECT cs.id AS session_id, cs.duration_minutes, cs.is_free_trial, cs.topic_sensitivity, cs.created_at
FROM chat_request_notifications crn
JOIN chat_sessions cs ON cs.id = crn.session_id
WHERE crn.mitra_id = ${mitraId}
@@ -322,7 +327,8 @@ export const getPendingRequestsForMitra = async (mitraId) => {
export const getSessionStatus = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
m.display_name AS mitra_display_name
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id

View File

@@ -0,0 +1,36 @@
import bcrypt from 'bcrypt'
const BCRYPT_COST = 12
// Password complexity: min 8 chars, >=1 digit, >=1 upper, >=1 lower
export const validatePasswordComplexity = (plain) => {
if (typeof plain !== 'string' || plain.length < 8) {
throw Object.assign(new Error('Password must be at least 8 characters'), {
code: 'PASSWORD_TOO_SHORT', statusCode: 422,
})
}
if (!/[0-9]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one digit'), {
code: 'PASSWORD_MISSING_DIGIT', statusCode: 422,
})
}
if (!/[A-Z]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one uppercase letter'), {
code: 'PASSWORD_MISSING_UPPERCASE', statusCode: 422,
})
}
if (!/[a-z]/.test(plain)) {
throw Object.assign(new Error('Password must contain at least one lowercase letter'), {
code: 'PASSWORD_MISSING_LOWERCASE', statusCode: 422,
})
}
}
export const hashPassword = async (plain) => {
return bcrypt.hash(plain, BCRYPT_COST)
}
export const verifyPassword = async (plain, hash) => {
if (!hash) return false
return bcrypt.compare(plain, hash)
}

View File

@@ -0,0 +1,90 @@
import { getDb } from '../db/client.js'
import { sendToSessionParticipant } from '../plugins/websocket.js'
import { getSensitivityConfig } from './config.service.js'
import { SessionStatus, TopicSensitivity, UserType, WsMessage } from '../constants.js'
const sql = getDb()
const FLIPPABLE_STATUSES = [SessionStatus.ACTIVE, SessionStatus.EXTENDING]
export const flipSessionSensitivity = async ({ sessionId, mitraId, toValue }) => {
if (toValue !== TopicSensitivity.REGULAR && toValue !== TopicSensitivity.SENSITIVE) {
throw Object.assign(new Error('Invalid topic_sensitivity value'), {
code: 'INVALID_TOPIC', statusCode: 400,
})
}
const [session] = await sql`
SELECT id, mitra_id, status, topic_sensitivity
FROM chat_sessions
WHERE id = ${sessionId}
`
if (!session) {
throw Object.assign(new Error('Session not found'), {
code: 'SESSION_NOT_FOUND', statusCode: 404,
})
}
if (session.mitra_id !== mitraId) {
throw Object.assign(new Error('Not allowed'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (!FLIPPABLE_STATUSES.includes(session.status)) {
throw Object.assign(new Error('Session is not in a flippable state'), {
code: 'SESSION_NOT_ACTIVE', statusCode: 409,
})
}
const fromValue = session.topic_sensitivity
if (fromValue === toValue) {
return { session_id: sessionId, topic_sensitivity: fromValue, changed: false }
}
const { one_way_latch } = await getSensitivityConfig()
if (one_way_latch && fromValue === TopicSensitivity.SENSITIVE && toValue === TopicSensitivity.REGULAR) {
throw Object.assign(new Error('Sensitive flag is locked and cannot be reverted'), {
code: 'SENSITIVITY_LATCHED', statusCode: 409,
})
}
const [log] = await sql.begin(async (tx) => {
await tx`
UPDATE chat_sessions
SET topic_sensitivity = ${toValue}
WHERE id = ${sessionId}
`
return tx`
INSERT INTO session_sensitivity_log (session_id, changed_by_mitra_id, from_value, to_value)
VALUES (${sessionId}, ${mitraId}, ${fromValue}, ${toValue})
RETURNING id, created_at
`
})
const payload = {
type: WsMessage.SESSION_TOPIC_UPDATED,
session_id: sessionId,
topic_sensitivity: toValue,
changed_at: log.created_at,
}
sendToSessionParticipant(sessionId, UserType.CUSTOMER, payload)
sendToSessionParticipant(sessionId, UserType.MITRA, payload)
return {
session_id: sessionId,
topic_sensitivity: toValue,
changed: true,
changed_at: log.created_at,
}
}
export const getSessionSensitivityLog = async (sessionId) => {
const rows = await sql`
SELECT l.id, l.session_id, l.changed_by_mitra_id, m.display_name AS changed_by_mitra_name,
l.from_value, l.to_value, l.created_at
FROM session_sensitivity_log l
LEFT JOIN mitras m ON m.id = l.changed_by_mitra_id
WHERE l.session_id = ${sessionId}
ORDER BY l.created_at ASC
`
return rows
}

View File

@@ -6,7 +6,7 @@ const sql = getDb()
export const getActiveSessionByCustomer = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name
FROM chat_sessions cs
@@ -20,7 +20,7 @@ export const getActiveSessionByCustomer = async (customerId) => {
export const getActiveSessionsByMitra = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name
FROM chat_sessions cs
@@ -120,14 +120,21 @@ export const rerouteSession = async (sessionId, newMitraId) => {
return session
}
export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
export const listSessions = async ({ page = 1, limit = 20, status, topic_sensitivity } = {}) => {
const offset = (page - 1) * limit
const conditions = status
? sql`WHERE cs.status = ${status}`
: sql``
let conditions = sql``
if (status && topic_sensitivity) {
conditions = sql`WHERE cs.status = ${status} AND cs.topic_sensitivity = ${topic_sensitivity}`
} else if (status) {
conditions = sql`WHERE cs.status = ${status}`
} else if (topic_sensitivity) {
conditions = sql`WHERE cs.topic_sensitivity = ${topic_sensitivity}`
}
const items = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
FROM chat_sessions cs
@@ -143,7 +150,8 @@ export const listSessions = async ({ page = 1, limit = 20, status } = {}) => {
export const getSessionById = async (sessionId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
m.display_name AS mitra_display_name
@@ -159,7 +167,7 @@ export const getSessionById = async (sessionId) => {
export const getActiveSessionByCustomerWithUnread = async (customerId) => {
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.expires_at, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT COUNT(*) FROM chat_messages cm
@@ -176,7 +184,7 @@ export const getActiveSessionByCustomerWithUnread = async (customerId) => {
export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
const sessions = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at,
cs.duration_minutes, cs.expires_at, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT COUNT(*) FROM chat_messages cm
@@ -194,7 +202,7 @@ export const getActiveSessionsByMitraWithUnread = async (mitraId) => {
export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT cs.id, cs.mitra_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
SELECT cs.id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
m.display_name AS mitra_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,
@@ -215,7 +223,7 @@ export const getCustomerHistory = async (customerId, { page = 1, limit = 20 } =
export const getMitraHistory = async (mitraId, { page = 1, limit = 20 } = {}) => {
const offset = (page - 1) * limit
const items = await sql`
SELECT cs.id, cs.customer_id, cs.status, cs.created_at, cs.paired_at, cs.ended_at,
SELECT cs.id, cs.customer_id, cs.status, cs.topic_sensitivity, cs.created_at, cs.paired_at, cs.ended_at,
cs.duration_minutes, cs.price, cs.is_free_trial, cs.extended_minutes,
c.display_name AS customer_display_name,
(SELECT message FROM session_closures WHERE session_id = cs.id AND user_type = ${UserType.MITRA} LIMIT 1) AS mitra_closure_message,

View File

@@ -0,0 +1,90 @@
import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
const getGoogleClientIds = () => {
const raw = process.env.GOOGLE_OAUTH_CLIENT_IDS || ''
return raw.split(',').map((s) => s.trim()).filter(Boolean)
}
const googleClient = new OAuth2Client()
/**
* Verify a Google ID token against Google's JWKS.
* Throws on invalid; returns { sub, email, email_verified, name } on success.
*/
export const verifyGoogleIdToken = async (idToken) => {
const audience = getGoogleClientIds()
if (audience.length === 0) {
throw Object.assign(new Error('GOOGLE_OAUTH_CLIENT_IDS not configured'), {
code: 'OAUTH_MISCONFIGURED', statusCode: 500,
})
}
try {
const ticket = await googleClient.verifyIdToken({ idToken, audience })
const payload = ticket.getPayload()
if (!payload?.sub) {
throw new Error('Google token missing sub')
}
return {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified === true,
name: payload.name,
}
} catch (err) {
throw Object.assign(new Error(err.message || 'Invalid Google token'), {
code: 'GOOGLE_TOKEN_INVALID', statusCode: 401,
})
}
}
const appleJwks = jwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys',
cache: true,
cacheMaxAge: 24 * 60 * 60 * 1000,
rateLimit: true,
})
const getApplePublicKey = (kid) => new Promise((resolve, reject) => {
appleJwks.getSigningKey(kid, (err, key) => {
if (err) return reject(err)
resolve(key.getPublicKey())
})
})
/**
* Verify an Apple ID token against Apple's JWKS.
* Throws on invalid; returns { sub, email? } on success.
* Note: Apple only includes `email` on first sign-in; persist it then.
*/
export const verifyAppleIdToken = async (idToken) => {
const audience = process.env.APPLE_SERVICES_ID
if (!audience) {
throw Object.assign(new Error('APPLE_SERVICES_ID not configured'), {
code: 'OAUTH_MISCONFIGURED', statusCode: 500,
})
}
try {
const decoded = jwt.decode(idToken, { complete: true })
if (!decoded?.header?.kid) throw new Error('Apple token missing kid header')
const publicKey = await getApplePublicKey(decoded.header.kid)
const payload = jwt.verify(idToken, publicKey, {
algorithms: ['RS256'],
audience,
issuer: 'https://appleid.apple.com',
})
if (!payload?.sub) throw new Error('Apple token missing sub')
return {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified === true || payload.email_verified === 'true',
}
} catch (err) {
throw Object.assign(new Error(err.message || 'Invalid Apple token'), {
code: 'APPLE_TOKEN_INVALID', statusCode: 401,
})
}
}

View File

@@ -0,0 +1,178 @@
import crypto from 'node:crypto'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import { getDb } from '../db/client.js'
const sql = getDb()
const REFRESH_HASH_COST = 10
const getJwtSecret = () => {
const secret = process.env.AUTH_JWT_SECRET
if (!secret || secret.length < 32) {
throw new Error('AUTH_JWT_SECRET is missing or too short (min 32 chars)')
}
return secret
}
const getAccessTokenTtlSeconds = () => {
const raw = process.env.ACCESS_TOKEN_TTL_SECONDS
const n = raw ? parseInt(raw, 10) : 3600
return Number.isFinite(n) && n > 0 ? n : 3600
}
const getRefreshTokenTtlDays = () => {
const raw = process.env.REFRESH_TOKEN_TTL_DAYS
const n = raw ? parseInt(raw, 10) : 30
return Number.isFinite(n) && n > 0 ? n : 30
}
const generateRefreshTokenRaw = () => {
return crypto.randomBytes(32).toString('base64url')
}
const signAccessToken = ({ userType, userId, sessionId }) => {
return jwt.sign(
{ user_type: userType, session_id: sessionId },
getJwtSecret(),
{
algorithm: 'HS256',
expiresIn: getAccessTokenTtlSeconds(),
subject: userId,
},
)
}
/**
* Verify an access token. Returns { userType, userId, sessionId } on success.
* Throws an error with code/statusCode on failure.
*/
export const verifyAccessToken = (token) => {
try {
const decoded = jwt.verify(token, getJwtSecret(), { algorithms: ['HS256'] })
if (!decoded || typeof decoded !== 'object') {
throw new Error('Malformed token')
}
return {
userType: decoded.user_type,
userId: decoded.sub,
sessionId: decoded.session_id,
}
} catch (err) {
const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID'
throw Object.assign(new Error(err.message || 'Invalid token'), {
code, statusCode: 401,
})
}
}
/**
* Create a new auth_sessions row and return tokens.
* deviceInfo is optional JSONB: { user_agent, ip }
*/
export const issueTokens = async ({ userType, userId, deviceInfo }) => {
const refreshRaw = generateRefreshTokenRaw()
const refreshHash = await bcrypt.hash(refreshRaw, REFRESH_HASH_COST)
const ttlDays = getRefreshTokenTtlDays()
const [session] = await sql`
INSERT INTO auth_sessions (user_type, user_id, refresh_token_hash, device_info, expires_at)
VALUES (
${userType}, ${userId}, ${refreshHash}, ${deviceInfo ? sql.json(deviceInfo) : null},
NOW() + (${ttlDays} || ' days')::interval
)
RETURNING id, expires_at
`
const accessToken = signAccessToken({ userType, userId, sessionId: session.id })
const accessTtlSeconds = getAccessTokenTtlSeconds()
return {
access_token: accessToken,
access_token_expires_in: accessTtlSeconds,
refresh_token: `${session.id}.${refreshRaw}`,
refresh_token_expires_at: session.expires_at,
session_id: session.id,
}
}
/**
* Validate a refresh token and rotate it. Returns new token pair.
* Refresh token format: "<session_id>.<raw>"
*/
export const refreshTokens = async ({ refreshToken, deviceInfo }) => {
if (typeof refreshToken !== 'string' || !refreshToken.includes('.')) {
throw Object.assign(new Error('Invalid refresh token'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
const [sessionId, raw] = refreshToken.split('.', 2)
const [row] = await sql`
SELECT id, user_type, user_id, refresh_token_hash, expires_at, revoked_at
FROM auth_sessions
WHERE id = ${sessionId}
`
if (!row || row.revoked_at || new Date(row.expires_at) <= new Date()) {
throw Object.assign(new Error('Refresh token expired or revoked'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
const ok = await bcrypt.compare(raw, row.refresh_token_hash)
if (!ok) {
throw Object.assign(new Error('Refresh token mismatch'), {
code: 'REFRESH_INVALID', statusCode: 401,
})
}
// Rotate: issue a new raw, update the hash, bump last_used_at and expires_at
const newRaw = generateRefreshTokenRaw()
const newHash = await bcrypt.hash(newRaw, REFRESH_HASH_COST)
const ttlDays = getRefreshTokenTtlDays()
const [updated] = await sql`
UPDATE auth_sessions
SET refresh_token_hash = ${newHash},
last_used_at = NOW(),
expires_at = NOW() + (${ttlDays} || ' days')::interval,
device_info = ${deviceInfo ? sql.json(deviceInfo) : row.device_info ?? null}
WHERE id = ${sessionId}
RETURNING id, user_type, user_id, expires_at
`
const accessToken = signAccessToken({
userType: updated.user_type,
userId: updated.user_id,
sessionId: updated.id,
})
return {
access_token: accessToken,
access_token_expires_in: getAccessTokenTtlSeconds(),
refresh_token: `${updated.id}.${newRaw}`,
refresh_token_expires_at: updated.expires_at,
session_id: updated.id,
user_type: updated.user_type,
user_id: updated.user_id,
}
}
/**
* Revoke a single session (logout).
*/
export const revokeSession = async (sessionId) => {
await sql`
UPDATE auth_sessions SET revoked_at = NOW()
WHERE id = ${sessionId} AND revoked_at IS NULL
`
// Future: SADD session_id into Valkey 'revoked_sessions' with TTL = remaining access token life
}
/**
* Revoke every session for a user (admin ban / force logout everywhere).
*/
export const revokeAllSessionsForUser = async ({ userType, userId }) => {
await sql`
UPDATE auth_sessions SET revoked_at = NOW()
WHERE user_type = ${userType} AND user_id = ${userId} AND revoked_at IS NULL
`
}