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:
2026-10-16 12:15:20 +00:00
parent e57610a27c
commit 63e0457886
86 changed files with 5174 additions and 1929 deletions

View File

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

View File

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

View 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")
})

View File

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

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

View 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")
})

View File

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