server/WWebSso.mjs

import path from 'path'
import fs from 'fs'
import get from 'lodash-es/get.js'
import size from 'lodash-es/size.js'
import every from 'lodash-es/every.js'
import iseobj from 'wsemi/src/iseobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isstr from 'wsemi/src/isstr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import isErr from 'wsemi/src/isErr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import cint from 'wsemi/src/cint.mjs'
import strleft from 'wsemi/src/strleft.mjs'
import strright from 'wsemi/src/strright.mjs'
import strdelright from 'wsemi/src/strdelright.mjs'
import pm2resolve from 'wsemi/src/pm2resolve.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import replace from 'wsemi/src/replace.mjs'
import WServHapiServer from 'w-serv-hapi/src/WServHapiServer.mjs'
import WServOrm from 'w-serv-orm/src/WServOrm.mjs'
import ds from '../src/schema/index.mjs'
import * as s from '../src/plugins/mShare.mjs'
import srLogInit, { maskToken } from './srLog.mjs'
import srEmailInit from './srEmail.mjs'
import procCore from './procCore.mjs'
import procLang from './procLang.mjs'
import procProtect from './procProtect.mjs'
import procStaInfor from './procStaInfor.mjs'
import procSettings from './procSettings.mjs'
import { defaultPasswordPolicy } from './defaultPasswordPolicy.mjs'


/**
 * WWebSso 單一登入(SSO)伺服器
 *
 * 建立SSO web服務: 使用者登入/自動登入/登出, 自助註冊與email驗證, 變更/重設密碼與通知信,
 * token發放/驗證/更新, 後台使用者/金鑰/IP管理與統計資訊, 以及供外部系統查驗token與使用者資訊之API
 *
 * @class
 * @param {Function} WOrm 輸入資料庫ORM函數
 * @param {String} url 輸入資料庫連線字串,例如w-orm-lmdb為'./db',或w-orm-mongodb為'mongodb://username:password@$127.0.0.1:27017'
 * @param {String} db 輸入資料庫名稱字串
 * @param {String} [pathSettings='./settings.json'] 輸入設定檔案路徑字串,檔案為json5格式,非既存檔案則回退'./settings.json',預設'./settings.json'
 * @param {Object} [optExt={}] 輸入額外設定物件,與設定檔內容合併且同名鍵以optExt為準,以下各鍵亦可直接寫於設定檔(各鍵之預設值與範例詳套件自帶settings.json),預設{}
 * @param {Integer} [optExt.serverPort=11007] 輸入伺服器通訊port整數,預設11007
 * @param {Boolean} [optExt.useCheckUser=false] 輸入是否檢查使用者資訊布林值,預設false
 * @param {Function} [optExt.getUserById=null] 輸入當useCheckUser=true時依照使用者ID取得使用者資訊物件函數,預設null
 * @param {Boolean} [optExt.useExcludeWhenNotAdmin=false] 輸入使用ORM的select方法時是否自動刪除數據內isActive欄位之布林值,預設false
 * @param {Object} [optExt.webName={}] 輸入站台名稱物件,至少包含語系eng與cht鍵的名稱,預設{}
 * @param {Object} [optExt.webDescription={}] 輸入站台描述物件,至少包含語系eng與cht鍵的名稱,預設{}
 * @param {String} [optExt.webLogo=''] 輸入站台logo字串,採base64格式,預設''
 * @param {Array} [optExt.webBackgoundGradientColors=['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']] 輸入使用者登入頁顏色陣列,前3色為背景線性linear-gradient,第4色為登入區塊背景色,第5色為站台logo背景色,若不給則預設白色。預設['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']
 * @param {String} [optExt.webKey=''] 輸入站台識別字串,預設''
 * @param {String} [optExt.salt=''] 輸入密碼加鹽字串,預設''
 * @param {Integer} [optExt.minExpired=30] 輸入創建或更新金鑰有效時間整數,單位分鐘(min),預設30
 * @param {Integer} [optExt.minForAccountLoginFailed=10] 輸入限制帳號最大登入失敗(密碼錯誤)次數之判準時間整數,單位分鐘(min),預設10
 * @param {Integer} [optExt.numForAccountLoginFailed=3] 輸入限制帳號最大登入失敗(密碼錯誤)之次數整數,預設3
 * @param {Integer} [optExt.minBlockForAccountLoginFailed=30] 輸入限制帳號最大登入失敗(密碼錯誤)次數之觸發後封鎖時間整數,單位分鐘(min),預設30
 * @param {Integer} [optExt.minForTokenCallApi=10] 輸入限制token最大調用API次數之判準時間整數,單位分鐘(min),預設10
 * @param {Integer} [optExt.numForTokenCallApi=1000] 輸入限制token最大調用API之次數整數,預設1000
 * @param {Integer} [optExt.minBlockForTokenCallApi=30] 輸入限制token最大調用API次數之觸發後封鎖時間整數,單位分鐘(min),預設30
 * @param {Integer} [optExt.minForIpCallApi=10] 輸入限制IP最大調用API次數之判準時間整數,單位分鐘(min),預設10
 * @param {Integer} [optExt.numForIpCallApi=10000] 輸入限制IP最大調用API之次數整數,預設10000
 * @param {Integer} [optExt.minBlockForIpCallApi=30] 輸入限制IP最大調用API次數之觸發後封鎖時間整數,單位分鐘(min),預設30
 * @param {String} [optExt.userLogo=''] 輸入使用者logo字串,採base64格式,預設''
 * @param {String} [optExt.subfolder=''] 輸入站台所在子目錄字串,提供站台位於內網採反向代理進行服務時,故需支援位於子目錄情形,預設''
 * @param {String} [optExt.mappingBy='email'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',預設'email'
 * @param {Object} [optExt.kpLangExt={}] 輸入擴充前端語系物件,預設{}
 * @param {String} [optExt.logFd='./logs'] 輸入log紀錄儲存位置字串,預設'./logs'
 * @param {String} [optExt.logInterval='hr'] 輸入log紀錄檔案拆檔時長字串,預設'hr'
 * @param {Integer} [optExt.logNumKeep=null] 輸入log檔保留數上限正整數,超出者自最舊刪除,未給採w-syslog預設(hr模式365*24、day模式365),預設null
 * @param {String} [optExt.emSrcEmail=null] 輸入email寄信用email字串,預設null
 * @param {String} [optExt.emSrcPW=null] 輸入email寄信用密碼字串,預設null
 * @param {String} [optExt.emSrcHost=null] 輸入email寄信用host字串,預設null
 * @param {String} [optExt.emSrcPort=null] 輸入email寄信用port整數,預設null
 * @param {Boolean} [optExt.allowUserRegistration=false] 輸入是否開放使用者自助註冊布林值,新功能採opt-in,須明確給true才啟用,預設false
 * @param {String} [optExt.siteUrl=''] 輸入站台前端網址字串,選填(目前僅保留供未來使用,尚無功能讀取),預設''
 * @param {String} [optExt.verifyBaseUrl=''] 輸入後端API base URL字串,用於組出註冊驗證信內之連結,當allowUserRegistration=true且部署於非本機環境時必填,不給則回退'http://localhost:{serverPort}'(僅本機開發可用),預設''
 * @param {Object} [optExt.passwordPolicy={詳見server/defaultPasswordPolicy.mjs}] 輸入密碼政策物件,不給則採程式內建預設;有給則以內建預設為底淺層merge(可只給欲調整之欄位如minLength,未給欄位沿用內建),merge後13個子欄位(minLength、maxLength、requireLetter、requireUppercase、requireLowercase、requireDigit、requireSpecial、noSpace、onlyAscii、forbiddenChars、noConsecutiveCharsFromAccount、consecutiveCharsMinMatch、commonPasswordBlacklist)逐欄驗證,型別錯即啟動時throw,預設內建政策
 * @param {Array} [optExt.cleanKpIpCallApiForIps=['127.0.0.1','::1','::ffff:127.0.0.1']] 輸入允許呼叫/api/cleanKpIpCallApi的連線IP白名單陣列,預設本機IP
 * @param {String} [optExt.cleanKpIpCallApiForToken=''] 輸入呼叫/api/cleanKpIpCallApi須附帶之識別token字串,預設''
 * @param {Array} [optExt.cleanKpAccountLoginFailedForIps=['127.0.0.1','::1','::ffff:127.0.0.1']] 輸入允許呼叫/api/cleanKpAccountLoginFailed的連線IP白名單陣列,預設本機IP
 * @param {String} [optExt.cleanKpAccountLoginFailedForToken=''] 輸入呼叫/api/cleanKpAccountLoginFailed須附帶之識別token字串,預設''
 * @param {String} [optExt.pathTemplate=''] 輸入自訂結果頁模板資料夾路徑字串,內放verifyEmailResult.html即覆寫內建結果頁模板(缺檔回退套件內建),不給則採內建模板,預設''
 * @param {Object} [optExt.chpwEmTitle={}] 輸入變更密碼通知信標題物件,逐語系鍵(eng、cht)各自給文字,或給檔案路徑(絕對或基於啟動路徑之相對,檔案存在即讀檔作為文字,不存在則原樣視為文字),未給之語系採內建語系文字,預設{}
 * @param {Object} [optExt.chpwEmContent={}] 輸入變更密碼通知信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}與{name}置換符,未給之語系採內建語系文字,預設{}
 * @param {Object} [optExt.regVerifyEmTitle={}] 輸入註冊驗證信標題物件,逐語系鍵(eng、cht)各自給文字或檔案路徑(檔案存在即讀檔作為文字),未給之語系採內建語系文字,預設{}
 * @param {Object} [optExt.regVerifyEmContent={}] 輸入註冊驗證信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}、{name}與{verifyUrl}置換符,未給之語系採內建語系文字,預設{}
 * @param {Object} [optExt.resetPwEmTitle={}] 輸入重設密碼通知信標題物件,逐語系鍵(eng、cht)各自給文字或檔案路徑(檔案存在即讀檔作為文字),未給之語系採內建語系文字,預設{}
 * @param {Object} [optExt.resetPwEmContent={}] 輸入重設密碼通知信內容物件,逐語系鍵(eng、cht)各自給HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{sender}、{name}、{account}與{newPassword}置換符,未給之語系採內建語系文字,預設{}
 * @param {String} [optExt.verifyEmailResultContent=''] 輸入註冊驗證結果頁HTML字串或檔案路徑(檔案存在即讀檔作為內容),支援{title}與{message}置換符({message}依請求lang代入對應語系文字故單一模板即可),未給採pathTemplate之verifyEmailResult.html(缺檔回退套件內建模板),預設''
 * @returns {Object} 回傳物件,其內server為hapi伺服器實體,wsrv為w-converhp的伺服器事件物件,wsds為w-serv-webdata的伺服器事件物件,可監聽error事件
 */
function WWebSso(WOrm, url, db, pathSettings, optExt = {}) {
    let instWServHapiServer = null

    //check WOrm
    if (!isfun(WOrm)) {
        console.log('invalid WOrm', WOrm)
        throw new Error('invalid WOrm')
    }

    //check url
    if (!isestr(url)) {
        console.log('invalid url', url)
        throw new Error('invalid url')
    }

    //check db
    if (!isestr(db)) {
        console.log('invalid db', db)
        throw new Error('invalid db')
    }

    //check pathSettings
    if (!fsIsFile(pathSettings)) {
        pathSettings = './settings.json'
    }

    //setFilepath
    procSettings.setFilepath(pathSettings)

    //getSettings
    let opt = procSettings.getSettings()
    if (iseobj(optExt)) { //外部可傳入額外參數
        opt = {
            ...opt,
            ...optExt,
        }
    }

    //serverPort
    let serverPort = get(opt, 'serverPort')
    if (!ispint(serverPort)) {
        serverPort = 11007
    }
    serverPort = cint(serverPort)

    //srLog, 提前初始化(僅依賴opt之logFd/logInterval), 使後續啟動期訊息可雙寫入log
    let srLog = srLogInit(opt)

    //logBoot, 啟動期訊息: console 與 srLog 雙寫(stdout 供互動可見, srLog 供與其他事件同源查閱)
    let logBoot = (level, msg) => {
        console.log(`[${level.toUpperCase()}] ${msg}`)
        srLog[level]({ event: 'boot', msg })
    }

    //useCheckUser
    let useCheckUser = get(opt, 'useCheckUser', false)

    //getUserById
    let getUserById = get(opt, 'getUserById', null)

    //useExcludeWhenNotAdmin
    let useExcludeWhenNotAdmin = get(opt, 'useExcludeWhenNotAdmin', false)

    //webName
    let webName = get(opt, 'webName', {})

    //webDescription
    let webDescription = get(opt, 'webDescription', {})

    //webLogo
    let webLogo = get(opt, 'webLogo', '')

    //webBackgoundGradientColors
    let webBackgoundGradientColors = get(opt, 'webBackgoundGradientColors', [])
    if (!isearr(webBackgoundGradientColors)) {
        webBackgoundGradientColors = ['#FFE0B2', '#FFCC80', '#FFF59D', '#F2D6A2', '#F0CC88']
    }

    //webKey
    let webKey = get(opt, 'webKey', '')

    //salt (作為 per-user scrypt 之伺服器端 pepper; per-user 隨機 salt 已在 hashPassword 內處理)
    //D21: 強制要求引用方部署時注入真實 pepper (經 SALT 環境變數); 啟動時若為空/佔位符則拒啟.
    let salt = get(opt, 'salt', '')
    if (isestr(process.env.SALT)) {
        salt = process.env.SALT //生產環境經 SALT env 注入真實 pepper (優先於 settings 佔位符)
    }
    if (!isestr(salt) || salt === '{salt}') {
        //測試/開發可設 ALLOW_PLACEHOLDER_SALT=1 沿用佔位符 (測試密碼非機密); 生產一律須注入真實 SALT.
        if (!isestr(process.env.ALLOW_PLACEHOLDER_SALT)) {
            throw new Error(`SALT pepper 未設定: 請以環境變數 SALT 注入真實高熵 pepper 後再啟動 (測試/開發可設 ALLOW_PLACEHOLDER_SALT=1 沿用佔位符)`)
        }
        logBoot('warn', `SALT pepper 為佔位符/空值, 因 ALLOW_PLACEHOLDER_SALT 啟用而放行 — 切勿用於生產環境`)
    }

    //minExpired, 使用者成功登入後產生token之有效時間(分鐘)
    let minExpired = get(opt, 'minExpired', '')
    if (!ispnum(minExpired)) {
        minExpired = 30
    }

    //minForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)次數之判準時間(分鐘)
    let minForAccountLoginFailed = get(opt, 'minForAccountLoginFailed', '')
    if (!ispnum(minForAccountLoginFailed)) {
        minForAccountLoginFailed = 10
    }

    //numForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)之次數
    let numForAccountLoginFailed = get(opt, 'numForAccountLoginFailed', '')
    if (!ispnum(numForAccountLoginFailed)) {
        numForAccountLoginFailed = 3
    }

    //minBlockForAccountLoginFailed, 限制帳號最大登入失敗(密碼錯誤)次數之觸發後封鎖時間(分鐘)
    let minBlockForAccountLoginFailed = get(opt, 'minBlockForAccountLoginFailed', '')
    if (!ispnum(minBlockForAccountLoginFailed)) {
        minBlockForAccountLoginFailed = 30
    }

    //minForTokenCallApi, 限制token最大調用API次數之判準時間(分鐘)
    let minForTokenCallApi = get(opt, 'minForTokenCallApi', '')
    if (!ispnum(minForTokenCallApi)) {
        minForTokenCallApi = 10
    }

    //numForTokenCallApi, 限制token最大調用API之次數
    let numForTokenCallApi = get(opt, 'numForTokenCallApi', '')
    if (!ispnum(numForTokenCallApi)) {
        numForTokenCallApi = 1000
    }

    //minBlockForTokenCallApi, 限制token最大調用API次數之觸發後封鎖時間(分鐘)
    let minBlockForTokenCallApi = get(opt, 'minBlockForTokenCallApi', '')
    if (!ispnum(minBlockForTokenCallApi)) {
        minBlockForTokenCallApi = 30
    }

    //minForIpCallApi, 限制IP最大調用API次數之判準時間(分鐘)
    let minForIpCallApi = get(opt, 'minForIpCallApi', '')
    if (!ispnum(minForIpCallApi)) {
        minForIpCallApi = 10
    }

    //numForIpCallApi, 限制IP最大調用API之次數
    let numForIpCallApi = get(opt, 'numForIpCallApi', '')
    if (!ispnum(numForIpCallApi)) {
        numForIpCallApi = 10000
    }

    //minBlockForIpCallApi, 限制IP最大調用API次數之觸發後封鎖時間(分鐘)
    let minBlockForIpCallApi = get(opt, 'minBlockForIpCallApi', '')
    if (!ispnum(minBlockForIpCallApi)) {
        minBlockForIpCallApi = 30
    }

    //cleanKpIpCallApiForIps, 允許呼叫 /api/cleanKpIpCallApi 的連線 IP 白名單
    let cleanKpIpCallApiForIps = get(opt, 'cleanKpIpCallApiForIps', [])
    if (!isearr(cleanKpIpCallApiForIps)) {
        cleanKpIpCallApiForIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1']
    }

    //cleanKpIpCallApiForToken, 呼叫 /api/cleanKpIpCallApi 須附帶之識別 token
    let cleanKpIpCallApiForToken = get(opt, 'cleanKpIpCallApiForToken', '')

    //cleanKpAccountLoginFailedForIps, 允許呼叫 /api/cleanKpAccountLoginFailed 的連線 IP 白名單
    let cleanKpAccountLoginFailedForIps = get(opt, 'cleanKpAccountLoginFailedForIps', [])
    if (!isearr(cleanKpAccountLoginFailedForIps)) {
        cleanKpAccountLoginFailedForIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1']
    }

    //cleanKpAccountLoginFailedForToken, 呼叫 /api/cleanKpAccountLoginFailed 須附帶之識別 token
    let cleanKpAccountLoginFailedForToken = get(opt, 'cleanKpAccountLoginFailedForToken', '')

    //allowUserRegistration, 是否開放使用者自助註冊, 新功能採opt-in, 未給預設false, 引用方明確設true才啟用(===true嚴格判定, 免'y'等字串誤開)
    let allowUserRegistration = get(opt, 'allowUserRegistration', false) === true

    //siteUrl, 站台前端網址, 選填(目前僅保留傳遞供未來使用, 尚無功能讀取, 不強制安裝方提供)
    let siteUrl = get(opt, 'siteUrl', '')
    if (!isestr(siteUrl)) {
        siteUrl = ''
    }

    //passwordPolicy, 未給時採程式內建預設; 有給時以內建預設為底做淺層merge(只給部分欄位如minLength亦可, 未給欄位沿用內建, 未來新增欄位不構成破壞性變更), merge後逐欄嚴格驗證(給了就必須給對)
    let passwordPolicy = get(opt, 'passwordPolicy')
    if (!iseobj(passwordPolicy)) {
        passwordPolicy = defaultPasswordPolicy
        logBoot('info', 'settings 未提供 passwordPolicy, 採用程式內建預設密碼政策')
    }
    else {
        passwordPolicy = {
            ...defaultPasswordPolicy,
            ...passwordPolicy,
        }
    }
    let ppMinLength = get(passwordPolicy, 'minLength')
    if (!ispint(ppMinLength) || cint(ppMinLength) < 1) {
        throw new Error('invalid passwordPolicy.minLength: must be a positive integer >= 1')
    }
    let ppMaxLength = get(passwordPolicy, 'maxLength')
    if (!ispint(ppMaxLength) || cint(ppMaxLength) < cint(ppMinLength)) {
        throw new Error('invalid passwordPolicy.maxLength: must be a positive integer >= minLength')
    }
    let ppRequireLetter = get(passwordPolicy, 'requireLetter')
    if (!isbol(ppRequireLetter)) {
        throw new Error('invalid passwordPolicy.requireLetter: must be a boolean')
    }
    let ppRequireUppercase = get(passwordPolicy, 'requireUppercase')
    if (!isbol(ppRequireUppercase)) {
        throw new Error('invalid passwordPolicy.requireUppercase: must be a boolean')
    }
    let ppRequireLowercase = get(passwordPolicy, 'requireLowercase')
    if (!isbol(ppRequireLowercase)) {
        throw new Error('invalid passwordPolicy.requireLowercase: must be a boolean')
    }
    let ppRequireDigit = get(passwordPolicy, 'requireDigit')
    if (!isbol(ppRequireDigit)) {
        throw new Error('invalid passwordPolicy.requireDigit: must be a boolean')
    }
    let ppRequireSpecial = get(passwordPolicy, 'requireSpecial')
    if (!isbol(ppRequireSpecial)) {
        throw new Error('invalid passwordPolicy.requireSpecial: must be a boolean')
    }
    let ppNoSpace = get(passwordPolicy, 'noSpace')
    if (!isbol(ppNoSpace)) {
        throw new Error('invalid passwordPolicy.noSpace: must be a boolean')
    }
    let ppOnlyAscii = get(passwordPolicy, 'onlyAscii')
    if (!isbol(ppOnlyAscii)) {
        throw new Error('invalid passwordPolicy.onlyAscii: must be a boolean')
    }
    let ppForbiddenChars = get(passwordPolicy, 'forbiddenChars')
    if (!isarr(ppForbiddenChars)) {
        throw new Error('invalid passwordPolicy.forbiddenChars: must be an array')
    }
    for (let i = 0; i < size(ppForbiddenChars); i++) {
        if (!isstr(ppForbiddenChars[i]) || size(ppForbiddenChars[i]) !== 1) {
            throw new Error(`invalid passwordPolicy.forbiddenChars[${i}]: each element must be a single character string`)
        }
    }
    let ppNoConsecutiveCharsFromAccount = get(passwordPolicy, 'noConsecutiveCharsFromAccount')
    if (!isbol(ppNoConsecutiveCharsFromAccount)) {
        throw new Error('invalid passwordPolicy.noConsecutiveCharsFromAccount: must be a boolean')
    }
    if (ppNoConsecutiveCharsFromAccount) {
        let ppConsecutiveCharsMinMatch = get(passwordPolicy, 'consecutiveCharsMinMatch')
        if (!ispint(ppConsecutiveCharsMinMatch) || cint(ppConsecutiveCharsMinMatch) < 2) {
            throw new Error('invalid passwordPolicy.consecutiveCharsMinMatch: must be a positive integer >= 2')
        }
    }
    let ppCommonPasswordBlacklist = get(passwordPolicy, 'commonPasswordBlacklist')
    if (!isarr(ppCommonPasswordBlacklist)) {
        throw new Error('invalid passwordPolicy.commonPasswordBlacklist: must be an array')
    }
    if (!every(ppCommonPasswordBlacklist, (v) => isstr(v))) {
        throw new Error('invalid passwordPolicy.commonPasswordBlacklist: each element must be a string')
    }

    //userLogo
    let userLogo = get(opt, 'userLogo', '')

    //subfolder
    let subfolder = get(opt, 'subfolder', '')
    if (isestr(subfolder)) {
        if (strright(subfolder, 1) === '/') { //右邊不需要給「/」
            subfolder = strdelright(subfolder, 1)
        }
        if (strleft(subfolder, 1) !== '/') { //左邊需要給「/」
            subfolder = `/${subfolder}`
        }
    }

    //params
    let showLanguage = get(opt, 'showLanguage', 'n')
    let language = get(opt, 'language', 'eng')
    let showModeEditUsers = get(opt, 'showModeEditUsers', 'n')
    let modeEditUsers = get(opt, 'modeEditUsers', 'n')
    let showModeEditTokens = get(opt, 'showModeEditTokens', 'n')
    let modeEditTokens = get(opt, 'modeEditTokens', 'n')
    let showModeEditIps = get(opt, 'showModeEditIps', 'n')
    let modeEditIps = get(opt, 'modeEditIps', 'n')

    //mappingBy
    let mappingBy = get(opt, 'mappingBy', '')
    if (mappingBy !== 'id' && mappingBy !== 'email' && mappingBy !== 'name') {
        mappingBy = 'email'
    }
    // console.log('mappingBy', mappingBy)

    //kpLangExt
    let kpLangExt = get(opt, 'kpLangExt', {})

    //srEmail
    let srEmail = srEmailInit(opt)

    //readTextIfFile, 值若為既存檔案路徑(絕對或基於啟動路徑之相對)則於初始化讀檔內容作為模板或文字, 否則原樣視為文字
    let readTextIfFile = (v, label = '') => {
        if (!isestr(v)) {
            return v
        }
        let fp = path.resolve(v)
        if (fsIsFile(fp)) {
            return fs.readFileSync(fp, 'utf8')
        }
        //疑似檔案路徑(相對/絕對/磁碟機開頭或常見模板副檔名)但檔案不存在: 印WARN(含resolve後絕對路徑, 供排查cwd差異), 仍原樣視為文字
        if (/^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(v) || /\.(html|txt)$/i.test(v)) {
            logBoot('warn', `settings.${label} 疑似檔案路徑但檔案不存在(${fp}), 將原樣視為文字`)
        }
        return v
    }

    //getEmText, 讀取信件文字 settings 鍵(逐語系物件), 各語系值可為文字或檔案路徑(檔案存在即讀檔)
    let getEmText = (key) => {
        let kp = get(opt, key)
        if (!iseobj(kp)) {
            return {}
        }
        let r = {}
        Object.keys(kp).forEach((k) => {
            r[k] = readTextIfFile(kp[k], `${key}.${k}`)
        })
        return r
    }

    //kpLangExtEmail, 信件文字 settings 鍵(逐語系物件), 為安裝方於設定檔客製信件之正式介面;
    //讀到才逐語系覆寫 kpLang 內建同名鍵, 其後 kpLangExt 仍可全面覆寫(最終覆寫層, 供測試/CI)
    //chpwEmTitle/chpwEmContent: 變更密碼通知信({sender}/{name} 置換)
    //regVerifyEmTitle/regVerifyEmContent: 註冊驗證信({sender}/{name}/{verifyUrl} 置換)
    //resetPwEmTitle/resetPwEmContent: 重設密碼通知信({sender}/{name}/{account}/{newPassword} 置換)
    let kpLangExtEmail = {}
    let emTextKeys = ['chpwEmTitle', 'chpwEmContent', 'regVerifyEmTitle', 'regVerifyEmContent', 'resetPwEmTitle', 'resetPwEmContent']
    emTextKeys.forEach((k) => {
        let kp = getEmText(k)
        if (size(kp) > 0) {
            kpLangExtEmail[k] = kp
        }
    })

    //kpLang, 語系字典: 內建(procLang) → settings 信件文字鍵(逐語系覆寫) → kpLangExt(整鍵覆寫, 最終層)
    let kpLang = procLang({ kpLangExtEmail, kpLangExt, webName, webDescription })

    //kpLangWeb, 傳送前端之語系字典: 剔除僅後端寄信使用之信件文字鍵(前端零使用, 避免外洩後端內部文字與增肥前端酬載)
    let kpLangWeb = {}
    Object.keys(kpLang).forEach((lang) => {
        let kp = { ...kpLang[lang] }
        emTextKeys.forEach((k) => {
            delete kp[k]
        })
        kpLangWeb[lang] = kp
    })

    //WServOrm
    let optWServOrm = {
        useCheckUser,
        getUserById,
        useExcludeWhenNotAdmin,
    }
    let wp = {}
    try {
        wp = WServOrm(ds, WOrm, url, db, optWServOrm)
    }
    catch (err) {
        console.log('WServOrm init failed', err)
        throw err
    }
    let { woItems, procOrm } = wp

    //getWebInfor
    let getWebInfor = () => {
        return {

            // webName, //已併入kpLang
            // webDescription, //已併入kpLang
            webLogo,
            webBackgoundGradientColors,
            webKey,
            userLogo,

            showLanguage,
            language,
            kpLang: kpLangWeb, //前端字典(已剔除後端寄信專用鍵)

            showModeEditUsers,
            modeEditUsers,
            showModeEditTokens,
            modeEditTokens,
            showModeEditIps,
            modeEditIps,

            allowUserRegistration,
            passwordPolicyInfo: {
                minLength: passwordPolicy.minLength,
                maxLength: passwordPolicy.maxLength,
                requireLetter: passwordPolicy.requireLetter,
                requireUppercase: passwordPolicy.requireUppercase,
                requireLowercase: passwordPolicy.requireLowercase,
                requireDigit: passwordPolicy.requireDigit,
                requireSpecial: passwordPolicy.requireSpecial,
                consecutiveCharsMinMatch: passwordPolicy.consecutiveCharsMinMatch,
            },

        }
    }

    //procCore, procProtect, procStaInfor, procLang
    //verifyBaseUrl, 後端 API base URL,用於驗證信內連結; 可由settings提供, 未給回退本機網址(僅本機開發可用)
    let verifyBaseUrl = get(opt, 'verifyBaseUrl', '')
    if (!isestr(verifyBaseUrl)) {
        verifyBaseUrl = `http://localhost:${serverPort}`
        if (allowUserRegistration) {
            logBoot('warn', `settings 未提供 verifyBaseUrl, 註冊驗證信連結將採 ${verifyBaseUrl}, 非本機部署時收信人將無法完成驗證, 請於 settings 提供對外可連之後端 base URL`)
        }
    }

    //pathTemplateDefault 內建模板資料夾路徑(dev / npm 兩種來源), 現僅存放複雜模板 verifyEmailResult.html(驗證結果頁); 信件文字內建預設在 procLang.mjs
    //供本檔 /api/verifyEmail handler 渲染結果頁
    let pathTemplateDefault = './server/template'
    let npmPathTemplate = './node_modules/w-web-sso/server/template'
    if (fsIsFolder(npmPathTemplate)) {
        pathTemplateDefault = npmPathTemplate
    }

    //pathTemplate, 可由settings提供自訂模板資料夾供客製結果頁(verifyEmailResult.html), 缺檔回退內建模板; 未給則直接採內建
    let pathTemplate = get(opt, 'pathTemplate', '')
    if (!isestr(pathTemplate) || !fsIsFolder(pathTemplate)) {
        if (isestr(pathTemplate)) {
            logBoot('warn', `settings.pathTemplate 指定之資料夾不存在(${pathTemplate}), 改採套件內建模板`)
        }
        pathTemplate = pathTemplateDefault
    }

    //verifyEmailResultContent, 註冊驗證結果頁HTML(內含{title}/{message}置換符), 可由settings直接提供文字或檔案路徑(優先);
    //未給則於初始化解析模板檔一次(自訂資料夾缺檔回退內建), 供 /api/verifyEmail handler 置換使用
    let verifyEmailResultContent = readTextIfFile(get(opt, 'verifyEmailResultContent', ''), 'verifyEmailResultContent')
    if (!isestr(verifyEmailResultContent)) {
        let fpTpl = path.resolve(pathTemplate, 'verifyEmailResult.html')
        if (!fsIsFile(fpTpl)) {
            fpTpl = path.resolve(pathTemplateDefault, 'verifyEmailResult.html')
        }
        verifyEmailResultContent = fs.readFileSync(fpTpl, 'utf8')
    }

    let p = procCore(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, passwordPolicy, allowUserRegistration, siteUrl, verifyBaseUrl })
    let pp = procProtect(woItems, p, {
        srLog, //app token 跳過速率封鎖時記 debug (ADR-052)
        minForAccountLoginFailed,
        numForAccountLoginFailed,
        minBlockForAccountLoginFailed,
        minForTokenCallApi,
        numForTokenCallApi,
        minBlockForTokenCallApi,
        minForIpCallApi,
        numForIpCallApi,
        minBlockForIpCallApi,
    })
    //D43: 統計子系統(staLogs worker)之 log 掃描目錄須承接設定值, 不可硬讀預設 './logs'(與 srLog 實際寫入位置不一致)
    let logFd = get(opt, 'logFd', '')
    let pf = procStaInfor(woItems, p, { srLog, logFd })

    //kpfun 入口統一 user-input string guard, 防 NoSQL operator injection
    //(w-orm-lmdb 之 select 內部用 mingo.Query, 接受 {$ne: null} 等 operator object
    //→ 全表 scan + log 噪音; 雖被 _getGenUserByKV duplicate check + verifyPassword
    //+ hashPassword 三層防住無 auth bypass, 但 DoS 風險仍存. 詳 ADR-003 Consequences.)
    //錯訊統一映射至 ADR-006 'token expired' (token 系) / ADR-003 'incorrect user account or password'
    //(login 系) / 'invalid rows' (admin batch update 系), 不洩 type check 失敗 vs business 失敗.
    let _strictStr = (...vals) => vals.every((v) => isestr(v))

    //錯誤契約 (key-only): kpfun 邊界層一律 reject 後端 key 字串 (= procLang 字典 key 名), 前端以 $t(key)
    //顯示 (字典查無則回 key 本身) + 以字串值判斷錯誤種類 (如 err === 'userRegistrationNotVerified').
    //內層 (procCore/procProtect) 本就 reject key 字串, 邊界層不再包成 { key, msg } / 不在後端翻譯.

    //funCheckAdmin
    let funCheckAdmin = (tk, u) => {
        // console.log('tk', tk, 'u', u)
        let b = s.checkUserAdmin(u)
        // console.log('checkUserAdmin', b)
        return b
    }

    //pathStaticFiles
    let pathStaticFiles = 'dist'
    let npmPathStaticFiles = './node_modules/w-web-sso/dist'
    if (fsIsFolder(npmPathStaticFiles)) {
        pathStaticFiles = npmPathStaticFiles
    }
    // console.log('pathStaticFiles', pathStaticFiles)

    //subfolder
    let fnEntryIn = 'index.tmp'
    let fnEntryOut = 'index.html'
    try {
        let fpEntryIn = path.resolve(pathStaticFiles, fnEntryIn)
        if (!fsIsFile(fpEntryIn)) {
            fpEntryIn = path.resolve(pathStaticFiles, fnEntryOut) //本機開發另使用html替代tmp
        }
        if (!fsIsFile(fpEntryIn)) {
            console.log('fpEntryIn', fpEntryIn)
            throw new Error(`invalid fpEntryIn`)
        }
        let fpEntryOut = path.resolve(pathStaticFiles, fnEntryOut)
        let c = fs.readFileSync(fpEntryIn, 'utf8')
        c = replace(c, '/msso/', '{sfd}/') //方法同genEntry
        c = replace(c, '{sfd}', subfolder)
        c = replace(c, '{language}', language)
        fs.writeFileSync(fpEntryOut, c, 'utf8')
    }
    catch (err) {
        console.log(err)
        console.log(`can not generate ${fnEntryOut}`)
    }

    //apis
    let apis = [

        //sso不使用w-ui-loginout
        // {
        //     method: 'GET',
        //     path: '/api/getUserByToken', //未登入主頁時需先檢測token, getUserByToken為w-ui-loginout預設值, 若要更改兩邊須同時修改
        // },

        {
            method: 'GET',
            path: '/api/cleanKpIpCallApi',
            handler: async function (req, res) {
                // console.log('cleanKpIpCallApi', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //比對 token 是否為 settings 的 cleanKpIpCallApiForToken, 未設定或不符一律拒絕
                    if (!isestr(cleanKpIpCallApiForToken)) {
                        return Promise.reject(`invalid cleanKpIpCallApiForToken`)
                    }

                    //比對 token 是否為 settings 的 cleanKpIpCallApiForToken, 未設定或不符一律拒絕
                    if (token !== cleanKpIpCallApiForToken) {
                        return Promise.reject(`cleanKpIpCallApi invalid token`)
                    }

                    //檢測ip須為 127.0.0.1 或 localhost
                    //realIp, 取 socket 真實連線位址 (不讀 x-forwarded-for, 該 header 可偽造會繞過本機限制)
                    let realIp = get(req, 'info.remoteAddress', '')
                    if (!isestr(realIp)) {
                        realIp = get(req, 'socket.remoteAddress', '')
                    }
                    if (!isestr(realIp)) {
                        realIp = get(req, 'connection.remoteAddress', '')
                    }

                    //僅放行 settings 的 cleanKpIpCallApiForIps 白名單內之連入 IP (供 e2e 防禦性清理 in-memory IP 計數器使用), 其餘一律拒絕
                    if (!cleanKpIpCallApiForIps.includes(realIp)) {
                        return Promise.reject(`cleanKpIpCallApi only allowed from cleanKpIpCallApiForIps, got realIp[${realIp}]`)
                    }

                    //info
                    srLog.info({ event: 'api/cleanKpIpCallApi', realIp })

                    //cleanKpIpCallApi, 清空 server in-memory 的 kpIpCallApi (所有 IP 的調用 API 計數)
                    await pp.cleanKpIpCallApi()

                    return true
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('cleanKpIpCallApi', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/cleanKpAccountLoginFailed',
            handler: async function (req, res) {
                // console.log('cleanKpAccountLoginFailed', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //比對 token 是否為 settings 的 cleanKpAccountLoginFailedForToken, 未設定或不符一律拒絕
                    if (!isestr(cleanKpAccountLoginFailedForToken)) {
                        return Promise.reject(`invalid cleanKpAccountLoginFailedForToken`)
                    }

                    //比對 token 是否為 settings 的 cleanKpAccountLoginFailedForToken, 未設定或不符一律拒絕
                    if (token !== cleanKpAccountLoginFailedForToken) {
                        return Promise.reject(`cleanKpAccountLoginFailed invalid token`)
                    }

                    //檢測ip須為 127.0.0.1 或 localhost
                    //realIp, 取 socket 真實連線位址 (不讀 x-forwarded-for, 該 header 可偽造會繞過本機限制)
                    let realIp = get(req, 'info.remoteAddress', '')
                    if (!isestr(realIp)) {
                        realIp = get(req, 'socket.remoteAddress', '')
                    }
                    if (!isestr(realIp)) {
                        realIp = get(req, 'connection.remoteAddress', '')
                    }

                    //僅放行 settings 的 cleanKpAccountLoginFailedForIps 白名單內之連入 IP (供 e2e 防禦性清理 in-memory 失敗計數器使用), 其餘一律拒絕
                    if (!cleanKpAccountLoginFailedForIps.includes(realIp)) {
                        return Promise.reject(`cleanKpAccountLoginFailed only allowed from cleanKpAccountLoginFailedForIps, got realIp[${realIp}]`)
                    }

                    //info
                    srLog.info({ event: 'api/cleanKpAccountLoginFailed', realIp })

                    //cleanKpAccountLoginFailed, 清空 server in-memory 的 kpAccountLoginFailed (所有帳號的登入失敗計數)
                    await pp.cleanKpAccountLoginFailed()

                    return true
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('cleanKpAccountLoginFailed', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/logoutSsoUser',
            handler: async function (req, res) {
                // console.log('logoutSsoUser', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //info
                    srLog.info({ event: 'api/logoutSsoUser', token: maskToken(token) })

                    //logoutByToken
                    let b = await p.logoutByToken(token)
                    // console.log('b', b)

                    return b
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('logoutSsoUser', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/checkToken',
            handler: async function (req, res) {
                // console.log('checkToken', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //info
                    srLog.info({ event: 'api/checkToken', token: maskToken(token) })

                    //callApiByToken
                    pp.callApiByToken(token)

                    //checkToken
                    let b = await p.checkToken(token)
                    // console.log('b', b)

                    return b
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('checkToken', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/refreshToken',
            handler: async function (req, res) {
                // console.log('refreshToken', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //info
                    srLog.info({ event: 'api/refreshToken', token: maskToken(token) })

                    //callApiByToken
                    pp.callApiByToken(token)

                    //refreshToken
                    let b = await p.refreshToken(token)
                    // console.log('b', b)

                    return b
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('refreshToken', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/logoutByToken',
            handler: async function (req, res) {
                // console.log('logoutByToken', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //info
                    srLog.info({ event: 'api/logoutByToken', token: maskToken(token) })

                    //callApiByToken
                    pp.callApiByToken(token)

                    //logoutByToken
                    let b = await p.logoutByToken(token)
                    // console.log('b', b)

                    return b
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('logoutByToken', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/getSsoUsersList',
            handler: async function (req, res) {
                // console.log('getSsoUsersList', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //info
                    srLog.info({ event: 'api/getSsoUsersList', token: maskToken(token) })

                    //callApiByToken
                    pp.callApiByToken(token)

                    //getSsoUsersList: 收緊為 admin-only (對齊 ADR-004 同款 IDOR 收緊 + 下方 kpfun 版本帶 funCheckAdmin).
                    //app token (isApp='y') 由 _checkTokenByObj 之 ADR-005 語意自動 bypass fun, 系統介接不影響.
                    let us = await p.checkTokenAndGetUsersList(token, { fun: funCheckAdmin })
                    // console.log('us', us)

                    return us
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('getSsoUsersList', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/getSsoUserInfor',
            handler: async function (req, res) {
                // console.log('getSsoUserInfor', req)

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')
                    // console.log('token', token)

                    //key
                    let key = get(req, 'query.key', '')
                    // console.log('key', key)

                    //value
                    let value = get(req, 'query.value', '')
                    // console.log('value', value)

                    //check(入口一致性, 對齊 kpfun getUserInfor 之 guard)
                    if (!_strictStr(token, key, value)) return Promise.reject('tokenExpired')

                    //valueLog: key==='token' 時 value 即明文目標 token, 記 log 前需遮罩
                    let valueLog = (key === 'token') ? maskToken(value) : value

                    //info
                    srLog.info({ event: 'api/getSsoUserInfor', token: maskToken(token), key, value: valueLog })

                    //callApiByToken
                    pp.callApiByToken(token)

                    //userTarget
                    let userTarget = null
                    if (key === 'token') {
                        userTarget = await p.checkTokenAndGetUserByToken(token, value, { fun: funCheckAdmin })
                        // console.log('checkTokenAndGetUserByToken userTarget', userTarget)
                    }
                    else {
                        userTarget = await p.checkTokenAndGetUserInfor(token, key, value, { fun: funCheckAdmin })
                        // console.log('checkTokenAndGetUserInfor userTarget', userTarget)
                    }

                    //check
                    if (!iseobj(userTarget)) {
                        // console.log('token', token)
                        // console.log('key', key, 'value', value)
                        console.log(`token does not have permission`)
                        return Promise.reject('tokenNoPermission') //原則5: 外部 API 錯誤回乾淨 key
                    }

                    return userTarget
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }
                // console.log('getSsoUserInfor', r)

                return r
            },
        },

        {
            method: 'GET',
            path: '/api/verifyEmail',
            handler: async function (req, res) {

                async function core() {

                    //token
                    let token = get(req, 'query.token', '')

                    //info
                    srLog.info({ event: 'api/verifyEmail', token: maskToken(token) })

                    //verifyEmail
                    let r = await p.verifyEmail(token)

                    return r
                }

                //pm2resolve core
                let r = await pm2resolve(core)()
                if (isErr(r.msg)) {
                    r.msg = r.msg.message
                }
                if (r.state === 'error') {
                    srLog.error({ event: 'api-error', key: r.msg }) //原則3: 後端錯誤 log error key
                }

                //lang from query (依語系直接渲染結果頁,避免轉址回 SPA 後 lang 重置)
                let lang = get(req, 'query.lang', '')
                if (lang !== 'eng' && lang !== 'cht') {
                    lang = 'eng'
                }

                //pick message key
                let msgKey
                if (r.state === 'success') {
                    msgKey = 'userRegistrationVerifySuccess'
                }
                else {
                    msgKey = (r.msg && typeof r.msg === 'string') ? r.msg : 'verifyEmailInvalidToken'
                }

                //render (verifyEmailResultContent 已於初始化解析: settings 直接提供優先, 否則模板檔)
                let title = get(kpLang, `${lang}.webName`, '')
                let message = get(kpLang, `${lang}.${msgKey}`, msgKey)

                let html = verifyEmailResultContent
                html = replace(html, '{title}', title)
                html = replace(html, '{message}', message)

                return res.response(html).type('text/html; charset=utf-8')
            },
        },

    ]

    //WServHapiServer
    let optHapi = {
        port: opt.serverPort,
        pathStaticFiles,
        apiName: 'api',
        apis,
        tokenType: 'Bearer',
        verifyConn: async ({ apiType, authorization, query, headers, req }) => {
            // console.log('headers', headers)

            //getIpByHeaders
            let ip = pp.getIpByHeaders(req)
            // console.log('ip', ip)

            //origin
            let origin = get(headers, 'origin', '')

            //referer
            let referer = get(headers, 'referer', '')

            //info
            srLog.info({ event: 'verifyConn', ip, origin, referer })

            //callApiByIp
            pp.callApiByIp(ip)

            //getBlockedByIp
            let b = true
            await pp.getBlockedByIp(ip)
                .then((res) => {
                    // console.log('pp.getBlockedByIp(ip) then', res)
                    b = res
                })
                .catch(() => {
                    // console.log('pp.getBlockedByIp(ip) catch', err)
                    b = true
                })
            // console.log('pp.getBlockedByIp(ip)', b)

            //b改為代表通行(未封鎖)狀態
            b = !b

            // //getInforsByIpCallApi
            // let rs = await pp.getInforsByIpCallApi(ip)
            // console.log('getInforsByIpCallApi', [
            //     rs[0],
            //     rs[rs.length - 1],
            // ], rs.length)

            //logshow
            if (!b) {
                console.log(`block ip[${ip}]`) //[tag:測試:顯示被封鎖ip]
            }

            return b
        },
        getUserIdByToken: async (token) => { //可使用async或sync函數
            return '' //不檢核, 查找與變更數據皆由kpFunExt內各函數各自檢核
        },
        corsOrigins: ['*'],
        useDbOrm: false,
        // kpOrm: woItems,
        // operOrm: procOrm, //procOrm的輸入為: userId, tableName, methodName, input
        tableNamesExec: [],
        methodsExec: [], //['select', 'insert', 'save', 'del', 'delAll'],
        tableNamesSync: [],
        kpFunExt: { //接收參數第1個為userId, 之後才是前端給予參數

            getWebInfor: async (_t) => {
                return getWebInfor()
            },

            checkToken: async (_t, token) => { //sso前端通過$fapi.checkToken調用
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-checkToken', token: maskToken(token) })
                let r = await p.checkToken(token)
                return r
            },

            refreshToken: async (_t, token) => { //sso前端通過$fapi.refreshToken調用
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-refreshToken', token: maskToken(token) })
                let r = await p.refreshToken(token)
                return r
            },

            loginByAccountAndPassword: async (_t, lang, account, password) => {
                if (!_strictStr(account, password)) return Promise.reject('failedLoginForCatch')

                //info, 使用者登入前
                srLog.info({ event: 'kpfun-loginByAccountAndPassword-before', account })

                let u = null
                let msg = ''
                await pp.loginByAccountAndPassword(account, password) //記得不能使用p.loginByAccountAndPassword, 此未提供保護機制
                    .then((res) => {
                        u = res
                    })
                    .catch((err) => {
                        msg = err
                    })

                //info, 使用者登入成功或失敗
                if (iseobj(u)) {
                    srLog.info({ event: 'kpfun-loginByAccountAndPassword-success', account })
                }
                else {
                    srLog.info({ event: 'kpfun-loginByAccountAndPassword-error', account, msg })
                }

                if (iseobj(u)) {
                    return u
                }
                //msg 為 procCore/procProtect reject 之 key (failedLoginForCatch / userRegistrationNotVerified
                /// loginAccountExpired / loginAccountBlocked), 直接以 key 字串 reject 回前端 (前端 $t 顯示)
                return Promise.reject(msg)
            },

            logoutByToken: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-logoutByToken', token: maskToken(token) })
                let r = await p.logoutByToken(token)
                return r
            },

            createUser: async (_t, lang, account, password, confirmPassword, name, email) => {
                srLog.info({ event: 'kpfun-createUser', lang, account })
                let data = { lang, account, password, confirmPassword, name, email }
                let r = await p.createUser(lang, data)
                return r
            },

            verifyEmail: async (_t, token) => {
                srLog.info({ event: 'kpfun-verifyEmail', token: maskToken(token) })
                let r = await p.verifyEmail(token)
                return r
            },

            resendVerifyEmail: async (_t, lang, account, email) => {
                srLog.info({ event: 'kpfun-resendVerifyEmail', lang, account })
                let r = await p.resendVerifyEmail(lang, account, email)
                return r
            },

            getUserByToken: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getUserByToken', token: maskToken(token) })
                //console.log('call getUserByToken...')
                let r = await p.checkTokenAndGetUserByToken(token, token)
                //console.log('call getUserByToken end')
                return r
            },

            getUserInfor: async (_t, token, key, value) => {
                if (!_strictStr(token, key, value)) return Promise.reject('tokenExpired')
                //valueLog: key==='token' 時 value 即明文目標 token, 記 log 前需遮罩
                let valueLog = (key === 'token') ? maskToken(value) : value
                srLog.info({ event: 'kpfun-getUserInfor', token: maskToken(token), key, value: valueLog })
                //console.log('call getUserInfor...')
                let r = await p.checkTokenAndGetUserInfor(token, key, value, { fun: funCheckAdmin })
                //console.log('call getUserInfor end')
                return r
            },

            checkUserPassword: async (_t, lang, pw) => {
                //僅記 event / lang, 不記任何密碼明文 (對齊 ADR-014 + adminResetUserPassword 之記法).
                srLog.info({ event: 'kpfun-checkUserPassword', lang })
                //console.log('call checkUserPassword...')
                let r = await p.checkUserPassword(lang, pw)
                //console.log('call checkUserPassword end')
                return r
            },

            changeUserPassword: async (_t, token, lang, pwOld, pwNew) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                //僅記 event / token / lang, 不記任何密碼明文 (對齊 ADR-014 + adminResetUserPassword 之記法).
                srLog.info({ event: 'kpfun-changeUserPassword', token: maskToken(token), lang })
                //console.log('call checkTokenAndChangePassword...')
                let r = await p.checkTokenAndChangePassword(token, lang, pwOld, pwNew)
                //console.log('call checkTokenAndChangePassword end')
                return r
            },

            adminResetUserPassword: async (_t, token, lang, targetUserId) => {
                if (!_strictStr(token, targetUserId)) return Promise.reject('tokenExpired')
                //僅記 event / token / targetUserId, 不記任何密碼明文
                srLog.info({ event: 'kpfun-adminResetUserPassword', token: maskToken(token), lang, targetUserId })
                let r = await p.adminResetUserPassword(token, lang, targetUserId)
                return r
            },

            getUsersList: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getUsersList', token: maskToken(token) })
                //console.log('call getUsersList...')
                let rs = await p.checkTokenAndGetUsersList(token, { fun: funCheckAdmin })
                //console.log('call getUsersList end')
                return rs
            },

            updateUsersList: async (_t, token, lang, rows) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
                srLog.info({ event: 'kpfun-updateUsersList', token: maskToken(token), lang })
                //console.log('call updateUsersList...')
                //內層 reject key 字串 (含 checkToken 之 tokenExpired) 直接透出回前端 (前端 $t 顯示)
                let rs = await p.checkTokenAndUpdateUsersList(token, lang, rows, { fun: funCheckAdmin })
                //console.log('call updateUsersList end')
                return rs
            },

            getTokensList: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getTokensList', token: maskToken(token) })
                //console.log('call getTokensList...')
                let rs = await p.checkTokenAndGetTokensList(token, { fun: funCheckAdmin })
                //console.log('call getTokensList end')
                return rs
            },

            updateTokensList: async (_t, token, lang, rows) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
                srLog.info({ event: 'kpfun-updateTokensList', token: maskToken(token), lang })
                //console.log('call updateTokensList...')
                let rs = await p.checkTokenAndUpdateTokensList(token, rows, { fun: funCheckAdmin })
                //console.log('call updateTokensList end')
                return rs
            },

            getIpsList: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getIpsList', token: maskToken(token) })
                //console.log('call getIpsList...')
                let rs = await p.checkTokenAndGetIpsList(token, { fun: funCheckAdmin })
                //console.log('call getIpsList end')
                return rs
            },

            updateIpsList: async (_t, token, lang, rows) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                if (!isearr(rows) || !rows.every((r) => isestr(get(r, 'id', '')))) return Promise.reject('invalidRows')
                srLog.info({ event: 'kpfun-updateIpsList', token: maskToken(token), lang })
                //console.log('call updateIpsList...')
                let rs = await p.checkTokenAndUpdateIpsList(token, rows, { fun: funCheckAdmin })
                //console.log('call updateIpsList end')
                return rs
            },

            getStaUserSummary: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaUserSummary', token: maskToken(token) })
                //console.log('call getStaUserSummary...')
                let r = await pf.checkTokenAndGetStaUserSummary(token, { fun: funCheckAdmin })
                //console.log('call getStaUserSummary end')
                return r
            },

            getStaTokenSummary: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaTokenSummary', token: maskToken(token) })
                //console.log('call getStaTokenSummary...')
                let r = await pf.checkTokenAndGetStaTokenSummary(token, { fun: funCheckAdmin })
                //console.log('call getStaTokenSummary end')
                return r
            },

            getStaIpSummary: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaIpSummary', token: maskToken(token) })
                //console.log('call getStaIpSummary...')
                let r = await pf.checkTokenAndGetStaIpSummary(token, { fun: funCheckAdmin })
                //console.log('call getStaIpSummary end')
                return r
            },

            getStaUserAccountLogin: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaUserAccountLogin', token: maskToken(token) })
                //console.log('call getStaUserAccountLogin...')
                let r = await pf.checkTokenAndGetStaUserAccountLogin(token, { fun: funCheckAdmin })
                //console.log('call getStaUserAccountLogin end')
                return r
            },

            getStaToken: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaToken', token: maskToken(token) })
                //console.log('call getStaToken...')
                let r = await pf.checkTokenAndGetStaToken(token, { fun: funCheckAdmin })
                //console.log('call getStaToken end')
                return r
            },

            getStaIp: async (_t, token) => {
                if (!_strictStr(token)) return Promise.reject('tokenExpired')
                srLog.info({ event: 'kpfun-getStaIp', token: maskToken(token) })
                //console.log('call getStaIp...')
                let r = await pf.checkTokenAndGetStaIp(token, { fun: funCheckAdmin })
                //console.log('call getStaIp end')
                return r
            },

        },
        fnTableTags: 'tableTags-web-sso.json',
    }

    //原則3: 後端錯誤統一 log error key. 集中包一層 kpFunExt — 任一 kpfun reject 時記 { fun, key } 再原樣
    //re-throw (不改既有行為/回傳契約). key 為字串時直接記; 非字串 (Error/物件, 內部 invariant) 取 message.
    optHapi.kpFunExt = Object.fromEntries(Object.entries(optHapi.kpFunExt).map(([fnName, fn]) => [
        fnName,
        async (...args) => {
            try {
                return await fn(...args)
            }
            catch (err) {
                srLog.error({ event: 'kpfun-error', fun: fnName, key: isestr(err) ? err : (isErr(err) ? err.message : String(err)) })
                throw err
            }
        },
    ]))

    instWServHapiServer = new WServHapiServer(optHapi)

    return instWServHapiServer
}


export default WWebSso