fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: prevent stale profile updates and logout from modifying a newer session.
|
||||
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type LoginRequest = {
|
||||
@@ -41,17 +44,17 @@ export async function fetchProfile() {
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfileRequest) {
|
||||
const stored = readSession()
|
||||
const data = await request<AuthSession>("/api/auth/profile/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const stored = readSession()
|
||||
const nextSession: AuthSession = {
|
||||
...data,
|
||||
accessToken: data.accessToken || stored?.accessToken || "",
|
||||
expiresAt: data.expiresAt || stored?.expiresAt,
|
||||
}
|
||||
writeSession(nextSession)
|
||||
if (isSameSession(stored, readSession())) writeSession(nextSession)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
@@ -65,11 +68,12 @@ export function uploadProfileAvatar(file: File) {
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const stored = readSession()
|
||||
try {
|
||||
await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
if (isSameSession(stored, readSession())) clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expireSession, readSession } from "@/lib/auth"
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
|
||||
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
|
||||
import { isSameSession } from "@/lib/session-request-guard"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
const API_BASE_URL =
|
||||
@@ -50,10 +53,10 @@ function normalizeApiRequestError(error: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
|
||||
const payload = (await response.json()) as JsonResult<T>
|
||||
if (!response.ok || !payload.success) {
|
||||
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
|
||||
if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
|
||||
expireSession()
|
||||
}
|
||||
throw new ApiRequestError(
|
||||
@@ -65,11 +68,10 @@ async function parseResult<T>(response: Response) {
|
||||
return payload.data
|
||||
}
|
||||
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
|
||||
const session = readSession()
|
||||
function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
|
||||
const authHeaders = new Headers(headers)
|
||||
|
||||
if (!skipAuth && session?.accessToken) {
|
||||
if (session?.accessToken) {
|
||||
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
|
||||
}
|
||||
if (
|
||||
@@ -101,7 +103,8 @@ export async function request<T>(
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
const requestSession = skipAuth ? null : readSession()
|
||||
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
||||
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
try {
|
||||
@@ -111,7 +114,7 @@ export async function request<T>(
|
||||
cache: "no-store",
|
||||
})
|
||||
onResponse?.(response)
|
||||
return await parseResult<T>(response)
|
||||
return await parseResult<T>(response, requestSession)
|
||||
} catch (error) {
|
||||
throw normalizeApiRequestError(error)
|
||||
}
|
||||
@@ -124,7 +127,8 @@ export async function requestBlob(
|
||||
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
|
||||
delete (rest as RequestOptions).baseUrl
|
||||
delete (rest as RequestOptions).onResponse
|
||||
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
|
||||
const requestSession = skipAuth ? null : readSession()
|
||||
const authHeaders = buildRequestHeaders(headers, requestSession, rest.body)
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
...rest,
|
||||
@@ -136,7 +140,7 @@ export async function requestBlob(
|
||||
const contentType = response.headers.get("Content-Type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
await parseResult<never>(response)
|
||||
await parseResult<never>(response, requestSession)
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
173
web/lib/api/im-session.test.mjs
Normal file
173
web/lib/api/im-session.test.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import test from "node:test"
|
||||
import vm from "node:vm"
|
||||
import ts from "typescript"
|
||||
|
||||
const storage = () => {
|
||||
const values = new Map()
|
||||
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
|
||||
}
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
|
||||
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
|
||||
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
|
||||
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
|
||||
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
|
||||
})
|
||||
|
||||
function harness(request) {
|
||||
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
|
||||
const localStorage = storage(), sessionStorage = storage()
|
||||
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
|
||||
const module = { exports: {} }
|
||||
const mocks = {
|
||||
"@/lib/api/client": { request },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
|
||||
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
|
||||
}
|
||||
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
|
||||
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
|
||||
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
|
||||
return { api: module.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
|
||||
}
|
||||
|
||||
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
|
||||
const pending = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => { calls++; return pending.promise })
|
||||
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
pending.resolve(exchange())
|
||||
const [left, right] = await Promise.all([first, second])
|
||||
assert.equal(left.identityKey, "guest:server-assigned")
|
||||
assert.equal(right.identityKey, left.identityKey)
|
||||
assert.equal(left.guestContext, "client-guest")
|
||||
await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test("guest token cannot be reused under a different initiating visitor context", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||
await h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "someone-else" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
const next = await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(next.identityKey, "guest:server-2")
|
||||
})
|
||||
|
||||
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => { calls++; return exchange() })
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
|
||||
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
|
||||
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
await h.api.ensureCustomerSession()
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test("late session exchange from old identity cannot overwrite new session", async () => {
|
||||
const old = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
|
||||
const first = h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "new-visitor" })
|
||||
const next = await h.api.ensureCustomerSession()
|
||||
old.resolve(exchange("guest:old-server"))
|
||||
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
|
||||
})
|
||||
|
||||
test("A to B to A entry changes invalidate old same-value exchange", async () => {
|
||||
const old = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
|
||||
const first = h.api.ensureCustomerSession()
|
||||
h.setConfig({ externalId: "B" })
|
||||
h.api.getCustomerIdentityScope()
|
||||
h.setConfig({ externalId: "client-guest" })
|
||||
await h.api.ensureCustomerSession()
|
||||
old.resolve(exchange("guest:old-A"))
|
||||
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
|
||||
})
|
||||
|
||||
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||
await h.api.ensureCustomerSession()
|
||||
const scope = h.api.getCustomerIdentityScope()
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerIdentityScope(), scope)
|
||||
await h.api.exchangeCustomerSession()
|
||||
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
|
||||
})
|
||||
|
||||
test("refresh with another auth mode or channel cannot poison cached session", async () => {
|
||||
const h = harness(async () => exchange())
|
||||
await h.api.ensureCustomerSession()
|
||||
const original = h.api.getCustomerSessionToken()
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||
})
|
||||
|
||||
test("signed identity still requires exact user ID and correct auth mode", async () => {
|
||||
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
|
||||
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
|
||||
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
|
||||
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||
})
|
||||
|
||||
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
|
||||
const h = harness(async () => exchange())
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
|
||||
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
|
||||
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
|
||||
assert.equal(h.api.getCustomerSessionToken(), renewed)
|
||||
})
|
||||
|
||||
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
|
||||
const routes = []
|
||||
const h = harness(async (route, options) => {
|
||||
routes.push(route)
|
||||
if (route.endsWith("session_refresh")) {
|
||||
assert.equal(options.method, "POST")
|
||||
assert.ok(options.headers.Authorization.startsWith("Bearer "))
|
||||
}
|
||||
return exchange("guest:original")
|
||||
})
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||
const resumed = await h.api.ensureCustomerSession()
|
||||
assert.equal(resumed.identityKey, cached.identityKey)
|
||||
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
|
||||
})
|
||||
|
||||
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
|
||||
let calls = 0
|
||||
const h = harness(async (route) => {
|
||||
calls++
|
||||
if (route.endsWith("session_refresh")) throw new Error("offline")
|
||||
return exchange("guest:original")
|
||||
})
|
||||
await h.api.ensureCustomerSession()
|
||||
const cached = h.api.readCustomerSession()
|
||||
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||
import { request } from "@/lib/api/client"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
@@ -147,6 +148,8 @@ export type ImCustomerSessionExchangeResponse = {
|
||||
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
|
||||
channelId: string
|
||||
baseUrl: string
|
||||
/** Local binding only. Anonymous identity itself is assigned by the server. */
|
||||
guestContext?: string
|
||||
}
|
||||
|
||||
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
|
||||
@@ -159,6 +162,18 @@ const OPEN_IM_CHANNEL_ID =
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
||||
|
||||
let entryIdentityExchangeKey = ""
|
||||
let observedIdentityContext = ""
|
||||
let identityGeneration = 0
|
||||
let observedSessionIdentity = ""
|
||||
let sessionIdentityGeneration = 0
|
||||
let pendingSessionExchange: {
|
||||
scope: string
|
||||
promise: Promise<ImCustomerSession>
|
||||
} | null = null
|
||||
let pendingSessionRefresh: {
|
||||
scope: string
|
||||
promise: Promise<ImCustomerSession>
|
||||
} | null = null
|
||||
|
||||
function buildGuestId() {
|
||||
return `guest_${generateUUID()}`
|
||||
@@ -208,6 +223,36 @@ function identityContextKey(config: RuntimeImConfig) {
|
||||
])
|
||||
}
|
||||
|
||||
// An opaque, non-secret scope for in-memory drafts and request fencing. Token
|
||||
// renewal does not change it, but a new entry identity/channel/server does.
|
||||
export function getCustomerEntryScope() {
|
||||
const config = getRuntimeImConfig()
|
||||
const context = JSON.stringify([
|
||||
identityContextKey(config),
|
||||
config.externalId || config.userToken ? "" : getGuestId(),
|
||||
])
|
||||
if (context !== observedIdentityContext) {
|
||||
observedIdentityContext = context
|
||||
identityGeneration += 1
|
||||
}
|
||||
return `customer-${identityGeneration}`
|
||||
}
|
||||
|
||||
export function getCustomerIdentityScope() {
|
||||
const entry = getCustomerEntryScope()
|
||||
const session = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
const identity = JSON.stringify([
|
||||
entry,
|
||||
session && matchesSessionIdentity(session, config) ? session.identityKey : "",
|
||||
])
|
||||
if (identity !== observedSessionIdentity) {
|
||||
observedSessionIdentity = identity
|
||||
sessionIdentityGeneration += 1
|
||||
}
|
||||
return `${entry}-${sessionIdentityGeneration}`
|
||||
}
|
||||
|
||||
function requireEntryToken(config: RuntimeImConfig) {
|
||||
if (config.requireUserToken && !config.userToken) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
@@ -217,6 +262,12 @@ function requireEntryToken(config: RuntimeImConfig) {
|
||||
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
|
||||
if (typeof session.identityKey !== "string") return false
|
||||
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
|
||||
const claims = sessionTokenClaims(session.customerSessionToken)
|
||||
const identityMode = config.userToken ? "jwt" : config.externalIdSignature ? "hmac" : "guest"
|
||||
// Legacy tokens predate channel/auth-mode isolation and must be exchanged.
|
||||
// This is only cache validation; the server verifies every token signature.
|
||||
if (claims?.identityKey !== session.identityKey || claims.channelCode !== config.channelId ||
|
||||
typeof claims.identityScope !== "string" || !claims.identityScope.startsWith(`v1:${identityMode}:`)) return false
|
||||
if (config.userToken || config.requireUserToken) {
|
||||
return (
|
||||
!!config.userToken &&
|
||||
@@ -226,10 +277,8 @@ function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImCon
|
||||
)
|
||||
}
|
||||
const externalId = config.externalId || getGuestId()
|
||||
return (
|
||||
session.identityKey === `guest:${externalId}` ||
|
||||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
|
||||
)
|
||||
if (config.externalIdSignature) return session.identityKey === `user:${externalId}`
|
||||
return session.identityKey.startsWith("guest:") && session.guestContext === externalId
|
||||
}
|
||||
|
||||
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
|
||||
@@ -242,7 +291,7 @@ function usableCustomerSession(session: ImCustomerSession | null, config: Runtim
|
||||
return true
|
||||
}
|
||||
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
|
||||
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null {
|
||||
try {
|
||||
const payload = token.split(".")[1]
|
||||
if (!payload) return null
|
||||
@@ -275,7 +324,7 @@ function isCustomerSessionValid(
|
||||
if (identityKey && session.identityKey !== identityKey) {
|
||||
return false
|
||||
}
|
||||
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
|
||||
return parseExpiresAt(session.expiresAt) > Date.now()
|
||||
}
|
||||
|
||||
export function readCustomerSession(): ImCustomerSession | null {
|
||||
@@ -320,12 +369,15 @@ export function applyCustomerSessionRefresh(payload?: {
|
||||
}
|
||||
const current = readCustomerSession()
|
||||
const config = getRuntimeImConfig()
|
||||
if (!current || !usableCustomerSession(current, config)) return
|
||||
// The response may arrive during the expiry safety window. It is still a
|
||||
// valid renewal of the same server-authorized identity, not a new identity.
|
||||
if (!current || !matchesSessionIdentity(current, config)) return
|
||||
// Decode only for cache consistency, never as proof of authenticity. The
|
||||
// server still verifies signatures. A late old-account websocket/HTTP refresh
|
||||
// must not overwrite the current account's stored customer session.
|
||||
const claims = sessionTokenClaims(token)
|
||||
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId) return
|
||||
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId ||
|
||||
claims?.identityScope !== sessionTokenClaims(current.customerSessionToken)?.identityScope) return
|
||||
writeCustomerSession({
|
||||
...current,
|
||||
customerSessionToken: token,
|
||||
@@ -395,7 +447,7 @@ function createRequestOptions(
|
||||
} {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
const identityScope = getCustomerIdentityScope()
|
||||
return {
|
||||
...init,
|
||||
skipAuth: true,
|
||||
@@ -404,7 +456,7 @@ function createRequestOptions(
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
onResponse: (response) => {
|
||||
if (contextKey === identityContextKey(getRuntimeImConfig())) {
|
||||
if (identityScope === getCustomerIdentityScope()) {
|
||||
applyCustomerSessionHeaders(response)
|
||||
}
|
||||
},
|
||||
@@ -428,7 +480,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export async function exchangeCustomerSession() {
|
||||
async function exchangeCustomerSessionForScope(identityScope: string) {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const contextKey = identityContextKey(config)
|
||||
@@ -441,13 +493,14 @@ export async function exchangeCustomerSession() {
|
||||
headers: createExchangeHeaders(),
|
||||
}
|
||||
)
|
||||
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
|
||||
if (identityScope !== getCustomerEntryScope()) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
const session: ImCustomerSession = {
|
||||
...result,
|
||||
channelId: config.channelId,
|
||||
baseUrl: config.baseUrl,
|
||||
...(!config.userToken && !config.externalIdSignature ? { guestContext: config.externalId || getGuestId() } : {}),
|
||||
}
|
||||
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
@@ -457,14 +510,66 @@ export async function exchangeCustomerSession() {
|
||||
return session
|
||||
}
|
||||
|
||||
export function exchangeCustomerSession() {
|
||||
const scope = getCustomerEntryScope()
|
||||
if (pendingSessionExchange?.scope === scope) {
|
||||
return pendingSessionExchange.promise
|
||||
}
|
||||
const promise = exchangeCustomerSessionForScope(scope)
|
||||
pendingSessionExchange = { scope, promise }
|
||||
void promise.then(
|
||||
() => {
|
||||
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
|
||||
},
|
||||
() => {
|
||||
if (pendingSessionExchange?.promise === promise) pendingSessionExchange = null
|
||||
}
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function ensureCustomerSession() {
|
||||
const config = getRuntimeImConfig()
|
||||
requireEntryToken(config)
|
||||
const cached = readCustomerSession()
|
||||
if (usableCustomerSession(cached, config)) return cached!
|
||||
if (usableCustomerSession(cached, config)) {
|
||||
if (parseExpiresAt(cached!.expiresAt) <= Date.now() + 30000) {
|
||||
return refreshCustomerSession(cached!)
|
||||
}
|
||||
return cached!
|
||||
}
|
||||
return exchangeCustomerSession()
|
||||
}
|
||||
|
||||
function refreshCustomerSession(cached: ImCustomerSession) {
|
||||
const scope = getCustomerIdentityScope()
|
||||
if (pendingSessionRefresh?.scope === scope) return pendingSessionRefresh.promise
|
||||
const refresh = async () => {
|
||||
// Refresh with proof of the existing identity. An unsigned exchange would
|
||||
// intentionally create a different guest and abandon the current history.
|
||||
const result = await request<ImCustomerSessionExchangeResponse>("/api/customer/session_refresh", {
|
||||
...createRequestOptions({ method: "POST" }),
|
||||
})
|
||||
if (scope !== getCustomerIdentityScope()) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
const renewed: ImCustomerSession = { ...cached, ...result }
|
||||
if (!isCustomerSessionValid(renewed) || !matchesSessionIdentity(renewed, getRuntimeImConfig()) ||
|
||||
renewed.identityKey !== cached.identityKey || renewed.customer.id !== cached.customer.id) {
|
||||
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
|
||||
}
|
||||
writeCustomerSession(renewed)
|
||||
return renewed
|
||||
}
|
||||
const promise = refresh()
|
||||
pendingSessionRefresh = { scope, promise }
|
||||
const clear = () => {
|
||||
if (pendingSessionRefresh?.promise === promise) pendingSessionRefresh = null
|
||||
}
|
||||
void promise.then(clear, clear)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function fetchImConversationDetail(id: number) {
|
||||
return request<ImConversationDetail>(`/api/conversation/${id}`, {
|
||||
...createRequestOptions(),
|
||||
|
||||
115
web/lib/api/session-isolation.test.mjs
Normal file
115
web/lib/api/session-isolation.test.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { isSameSession } from "../session-request-guard.ts"
|
||||
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||
|
||||
function fixture() {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
let expirations = 0
|
||||
const response = deferred()
|
||||
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
|
||||
"@/lib/auth": {
|
||||
readSession: () => session,
|
||||
expireSession: () => { session = null; expirations += 1 },
|
||||
},
|
||||
"@/lib/session-request-guard": { isSameSession },
|
||||
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
|
||||
return {
|
||||
client, response,
|
||||
get session() { return session },
|
||||
set session(next) { session = next },
|
||||
get expirations() { return expirations },
|
||||
}
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
|
||||
status: 401, headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
for (const method of ["request", "requestBlob"]) {
|
||||
test(`${method}: old auth failure cannot expire a newer login`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test")
|
||||
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.session.user.id, 2)
|
||||
assert.equal(f.expirations, 0)
|
||||
})
|
||||
|
||||
test(`${method}: matching auth failure expires only its own session`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test")
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.session, null)
|
||||
assert.equal(f.expirations, 1)
|
||||
})
|
||||
|
||||
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.client[method]("/test", { skipAuth: true })
|
||||
f.response.resolve(expiredResponse())
|
||||
await assert.rejects(pending, { errorCode: 3002 })
|
||||
assert.equal(f.expirations, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test("old logout and profile update cannot overwrite a new account", async () => {
|
||||
for (const action of ["logout", "updateProfile"]) {
|
||||
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||
const response = deferred()
|
||||
let writes = 0
|
||||
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
|
||||
"@/lib/auth": {
|
||||
readSession: () => session,
|
||||
clearSession: () => { session = null; writes += 1 },
|
||||
writeSession: (next) => { session = next; writes += 1 },
|
||||
},
|
||||
"@/lib/session-request-guard": { isSameSession },
|
||||
"@/lib/api/client": { request: () => response.promise },
|
||||
})
|
||||
const pending = auth[action]({ nickname: "Updated" })
|
||||
session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
|
||||
await pending
|
||||
assert.equal(session.user.id, 2)
|
||||
assert.equal(writes, 0)
|
||||
}
|
||||
})
|
||||
|
||||
for (const newerLogin of [true, false]) {
|
||||
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
|
||||
let session = null
|
||||
const pendingLogout = deferred()
|
||||
const redirects = []
|
||||
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
|
||||
react: {
|
||||
createContext: () => ({ Provider: "context" }),
|
||||
startTransition: (callback) => callback(),
|
||||
useCallback: (callback) => callback,
|
||||
useEffect: () => {},
|
||||
},
|
||||
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
|
||||
"next/navigation": {
|
||||
usePathname: () => "/dashboard",
|
||||
useRouter: () => ({ replace: (url) => redirects.push(url) }),
|
||||
},
|
||||
"@/components/session-provider": {
|
||||
SessionProvider: "session",
|
||||
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
|
||||
},
|
||||
"@/lib/auth": { readSession: () => session },
|
||||
})
|
||||
const dashboardElement = auth.AuthProvider({ children: null }).props.children
|
||||
const context = dashboardElement.type(dashboardElement.props)
|
||||
const signOut = context.props.value.signOut()
|
||||
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||
pendingLogout.resolve()
|
||||
await signOut
|
||||
assert.equal(redirects.length, newerLogin ? 0 : 1)
|
||||
})
|
||||
}
|
||||
67
web/lib/api/setup-readiness.test.mjs
Normal file
67
web/lib/api/setup-readiness.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadSetupAPI(request) {
|
||||
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
|
||||
fileName: "setup-readiness.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
require(name) {
|
||||
assert.equal(name, "@/lib/api/client")
|
||||
return { request }
|
||||
},
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("simulation normalizes null or absent arrays from older servers", async () => {
|
||||
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
|
||||
const api = await loadSetupAPI(async () => response)
|
||||
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||
assert.equal(Array.isArray(result.candidate_operators), true)
|
||||
assert.equal(result.candidate_operators.length, 0)
|
||||
assert.equal(Array.isArray(result.hops), true)
|
||||
assert.equal(result.hops.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("simulation preserves returned candidates", async () => {
|
||||
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
|
||||
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
|
||||
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||
assert.equal(result.candidate_operators, candidates)
|
||||
})
|
||||
|
||||
test("quick fix preview does not manufacture apply intent", async () => {
|
||||
const calls = []
|
||||
const api = await loadSetupAPI(async (url, options) => {
|
||||
calls.push({ url, body: JSON.parse(options.body) })
|
||||
return { applied: false, changes: [], preview_token: "reviewed" }
|
||||
})
|
||||
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
|
||||
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
|
||||
assert.equal(calls[0].body.entity_id, 17)
|
||||
assert.equal("apply" in calls[0].body, false)
|
||||
assert.equal("preview_token" in calls[0].body, false)
|
||||
})
|
||||
|
||||
test("explicit apply carries the reviewed target and fingerprint", async () => {
|
||||
let sent
|
||||
const api = await loadSetupAPI(async (_url, options) => {
|
||||
sent = JSON.parse(options.body)
|
||||
return { applied: true, changes: [], applied_actions: [] }
|
||||
})
|
||||
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
|
||||
assert.equal(sent.apply, true)
|
||||
assert.equal(sent.entity_id, 17)
|
||||
assert.equal(sent.preview_token, "current-preview")
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type SetupStepStatus = "ok" | "warning" | "error"
|
||||
@@ -101,31 +102,51 @@ export type SetupSimulateRouteResponse = {
|
||||
candidate_operators: SimulatedOperatorCandidate[]
|
||||
}
|
||||
|
||||
export type SetupQuickFixChange = {
|
||||
entity_type: string
|
||||
entity_id: number
|
||||
entity_name: string
|
||||
operation: "create" | "update"
|
||||
before: Record<string, unknown> | null
|
||||
after: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SetupQuickFixResponse = {
|
||||
applied: boolean
|
||||
preview_token: string
|
||||
changes: SetupQuickFixChange[]
|
||||
warning_codes: string[]
|
||||
applied_actions: string[]
|
||||
readiness: SetupReadinessResponse
|
||||
readiness?: SetupReadinessResponse
|
||||
}
|
||||
|
||||
export function fetchSetupReadiness() {
|
||||
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
|
||||
}
|
||||
|
||||
export function simulateSetupRoute(payload: {
|
||||
export async function simulateSetupRoute(payload: {
|
||||
channel_id?: number
|
||||
bypass_ai?: boolean
|
||||
}) {
|
||||
return request<SetupSimulateRouteResponse>(
|
||||
const result = await request<SetupSimulateRouteResponse>(
|
||||
"/api/dashboard/setup/simulate_route",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
hops: result.hops ?? [],
|
||||
candidate_operators: result.candidate_operators ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function applySetupQuickFix(payload: {
|
||||
action: SetupQuickFixAction
|
||||
entity_id?: number
|
||||
apply?: boolean
|
||||
preview_token?: string
|
||||
}) {
|
||||
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user