diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed0d495..99ec7093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-22 — 普通聊天按会话绑定的人物排盘 + +普通聊天不再在切换人物后仍用登录用户本人的资料。会话锁定的是本人或当前用户名下的另一份资料;对方资料缺失、被删或不完整时会拦住发送,不会退回本人。已有消息的会话不能改绑。报告、每日星语和生时校正仍只用本人资料。Skill 版本不变。 + ## 2026-09-21 — 侧栏当前会话是一整条高亮 选中或悬停会话时,标题和右边的「⋯」共用一块底,不再拆成两个块。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a909a0bb..021da328 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13199,3 +13199,19 @@ - 相关记录:BUG-976、BUG-977 - 复发自:无 - 修复版本:`2503c019` + +## BUG-997 | 切换人物后普通聊天仍用登录用户本人的出生资料 + +- 状态:resolved +- 首次发现:2026-09-22 +- 最近更新:2026-09-22 +- 影响面:普通聊天 `/api/consult` 的排盘对象,以及 `POST/PATCH /api/sessions` 的人物绑定 +- 用户现象:会话已经记下另一位人物,继续提问时计算和咨询上下文仍使用当前登录用户的 `profiles`。对方资料被删除后,发送还会退回本人。 +- 触发条件:会话绑定 `chart_profile_role=other` 与某个 `chart_profile_id` 后调用普通咨询;或客户端同时提交另一套出生字段。 +- 根因:`chart_profile_id/name/role` 只是客户端可写的展示快照。咨询读取会话时不取 `chart_profile_id`,准备阶段无条件 `loadProfile(userId)` 读取登录用户的 `profiles`。客户端出生字段因此可以和实际排盘对象不一致,缺失或越权的 other 资料也会落回本人。 +- 修复:新增服务端 subject resolver。`self` 与未绑定的旧会话只读当前用户权威 `profiles`;`other` 只读当前用户拥有的 `chart_profiles` 行,并在服务端重新派生姓名与角色。会话展示名、客户端 `name/year/month/day/hour/minute/city/lat/lon/tz` 都不能覆盖该结果。另一用户的 id、随机 id、角色不一致、删除、缺失或不完整资料一律失败,不退回本人,也不扣点、不调用模型。空会话可以由服务端按 id 填上角色和姓名;已有消息的会话拒绝改绑。刷新与深链只使用库里的绑定,不读取全局 `activeChartId`。未改表结构、计费语义或模型选择,也未接到报告、每日星语或生时校正。 +- 验证:`frontend/tests/consultation-subject-binding.test.ts` 10 pass / 0 fail,覆盖 self/other、越权与随机 id、角色与伪造姓名、删除/缺失/不完整、客户端出生字段冲突、咨询准备拿到 other 资料、失败不扣点且不进入准备、有消息后改绑拒绝、刷新保留绑定、并发删除后发送失败。与 `consultation-route-service`、`application-billing-contract`、`chat-session-write`、`chat-session-authority`、`consultation-workflow-contract`、`consultation-birth-time-mode` 合跑 88 tests、87 pass / 1 fail。单独再跑 `consultation-birth-time-mode` 为 9 pass / 1 fail;失败项是本机无法创建 skill symlink(EPERM),同文件的咨询 route 顺序断言通过。`tsc --noEmit` 通过;`npm run lint` 0 error。未部署。 +- 防复发:普通咨询在扣点前必须经 subject resolver。other 查询必须同时约束 `id` 与当前 `user_id`,失败不得调用 `loadProfile` 作为退路。客户端出生字段不得进入 `toolInput`。已有消息的会话绑定必须与库存值一致,否则拒绝整次 PATCH。 +- 相关记录:BUG-001、BUG-010、BUG-011、BUG-018、BUG-073、BUG-076、BUG-188 +- 复发自:无 +- 修复版本:本次分支提交(未推送,未部署) diff --git a/docs/tasks/PROGRESS-consultation-subject-binding-20260922.md b/docs/tasks/PROGRESS-consultation-subject-binding-20260922.md new file mode 100644 index 00000000..5e470a5f --- /dev/null +++ b/docs/tasks/PROGRESS-consultation-subject-binding-20260922.md @@ -0,0 +1,33 @@ +# 进度 · 普通聊天人物资料服务端真值与会话绑定(2026-09-22) + +基线:`10baeb2fa865743c428806e115ac92ca9d92fb71`(开工时 `origin/staging`)。分支 `codex/consultation-subject-binding-20260922`。未推送,未部署。 + +## 做了什么 + +- 新增 `frontend/src/lib/consultation-subject-resolver.ts`。`self` 和未绑定旧会话只读当前用户 `profiles`。`other` 只读当前用户拥有的 `chart_profiles`,服务端重新派生姓名和角色。 +- `prepareConsultationRoute` 在扣点前解析 subject。other 不继承登录用户的校正候选区间。客户端出生字段只被传入后丢弃。 +- `POST /api/consult` 读取 `chart_profile_id/name/role`,失败时返回固定中文错误,不扣点、不调用模型。 +- 会话创建与 PATCH 由服务端填写或核对绑定。空会话可以绑定;已有消息则拒绝改绑。刷新和深链只看库存绑定,不看 `activeChartId`。 + +## 断言变更 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `application-billing-contract.test.ts` 会话 select | `...,title,theme,chart_profile_role,context_summary` | 同一串中在 role 前增加 `chart_profile_id,chart_profile_name` | 普通咨询必须按库存人物 id 解析,不能只看 role | + +其余既有测试名未改。展示名与资料最新姓名不一致时,咨询仍使用资料里的姓名,不因此失败:任务书要求后续聊天用该人物最新资料,展示快照不是排盘真值。伪造姓名不能写入绑定,也不能补全缺姓名的资料;角色不一致则直接失败,且不退回本人。 + +## 验证 + +- `tsc --noEmit`:通过。 +- `npm run lint`:0 error(既有 warning 未改)。 +- `npx tsx --test tests/consultation-subject-binding.test.ts`:10 pass / 0 fail。 +- 同命令一起跑 `consultation-subject-binding.test.ts`、`consultation-route-service.test.ts`、`application-billing-contract.test.ts`、`chat-session-write.test.ts`、`chat-session-authority.test.ts`、`consultation-birth-time-mode.test.ts`、`consultation-workflow-contract.test.ts`:88 tests,87 pass / 1 fail。 +- 单独再跑 `consultation-birth-time-mode.test.ts`:9 pass / 1 fail。唯一失败是 `general agent runtime has no Jyotish skill package and no personal chart tool`,本机创建 skill symlink 报 EPERM。同文件里「consult route validates mode before billing」通过。 +- 未跑全量 `npm test`,未跑 `next build`。 + +## 留给下一轮人物选择 UI + +- 不改 `page.tsx`、首屏或聊天顶栏,不做选择器。 +- 不把 other 接到报告、每日星语或生时校正。 +- 全局 `activeChartId` 仍只在客户端;服务端不读它。 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index ee8ba674..b8445794 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -74,6 +74,11 @@ import { prepareConsultationRoute, type PreparedConsultationRoute, } from "@/lib/consultation-route-service"; +import { + ConsultationSubjectError, + consultationSubjectBindingFromSession, + consultationSubjectFailureResponse, +} from "@/lib/consultation-subject-resolver"; import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { loadGeneralDailyPanchangaContext, @@ -297,7 +302,7 @@ export async function POST(request: Request) { const { data: chatSession, error: chatSessionError } = await supabase .from("chat_sessions") - .select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary") + .select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary") .eq("id", parsed.data.sessionId) .eq("user_id", user.id) .maybeSingle(); @@ -393,6 +398,23 @@ export async function POST(request: Request) { prepared = await prepareConsultationRoute({ userId, mode: parsed.data.consultationMode, + subject: { + binding: consultationSubjectBindingFromSession(chatSession), + clientBirth: parsed.data, + async loadOwnedChartProfile(chartProfileId) { + const { data, error } = await supabase + .from("chart_profiles") + .select("id,user_id,role,profile") + .eq("id", chartProfileId) + .eq("user_id", userId) + .maybeSingle(); + if (error) throw new ConsultationSubjectError("subject_unavailable"); + if (!data || typeof data.id !== "string" || typeof data.user_id !== "string" || typeof data.role !== "string") { + return null; + } + return { id: data.id, userId: data.user_id, role: data.role, profile: data.profile }; + }, + }, async loadProfile(profileUserId) { const { data, error } = await supabase .from("profiles") @@ -458,6 +480,10 @@ export async function POST(request: Request) { { status: 409 }, ); } + if (error instanceof ConsultationSubjectError) { + const failure = consultationSubjectFailureResponse(error); + return NextResponse.json(failure.body, { status: failure.status }); + } if (error instanceof ConsultationProfileTruthError) { const modeChanged = error.code === "mode_changed"; return NextResponse.json( @@ -483,6 +509,10 @@ export async function POST(request: Request) { ); } + const subjectName = prepared.subject?.name + || prepared.serverChart?.name + || prepared.declaredWindow?.name + || ""; const resolvedQuestion = resolveConsultationQuestion({ visibleQuestion: parsed.data.question, entrypoint: parsed.data.entrypoint, @@ -593,7 +623,7 @@ export async function POST(request: Request) { model: selectedModel, question: visibleQuestion, history: chatSession.messages, - name: parsed.data.name, + name: subjectName, signal: request.signal, onObservation(observation) { if (!observation.late) { @@ -803,7 +833,7 @@ export async function POST(request: Request) { model: selectedModel, question: parsed.data.question, theme: consultationTheme, - chartRole: chatSession.chart_profile_role === "other" ? "other" : "self", + chartRole: prepared.subject?.role === "other" ? "other" : "self", signal: request.signal, }).then(async (title) => { if (!title) return null; @@ -1359,7 +1389,7 @@ export async function POST(request: Request) { try { const history = storedHistory; - const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name; + const name = subjectName; const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode; const generalDailyContext = shouldLoadGeneralDailyPanchanga({ consultationMode, diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index e965bde5..338cc874 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -10,6 +10,15 @@ import { import { logIgnoredSessionMessages } from "@/lib/chat-session-observability"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { metadataUpdateValues } from "@/lib/session-metadata-update"; +import { + authoritativeSessionBinding, + ConsultationSubjectError, + consultationSubjectBindingFromSession, + consultationSubjectFailureResponse, + ownedChartProfileFromResult, + selfNameFromResult, + sessionBindingRequested, +} from "@/lib/consultation-subject-resolver"; type RouteContext = { params: Promise<{ id: string }> }; @@ -91,6 +100,34 @@ export async function PATCH(request: Request, context: RouteContext) { if (!values) { return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); } + if (sessionBindingRequested(values)) { + const { data: existing, error: existingError } = await supabase + .from("chat_sessions") + .select("messages,chart_profile_id,chart_profile_name,chart_profile_role") + .eq("id", id) + .eq("user_id", user.id) + .maybeSingle(); + if (existingError) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); + if (!existing) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 }); + const binding = await authoritativeSessionBinding({ + userId: user.id, + requested: values, + existingMessages: existing.messages, + existingBinding: consultationSubjectBindingFromSession(existing), + loadSelfProfile: async (profileUserId) => selfNameFromResult(await supabase + .from("profiles") + .select("name") + .eq("id", profileUserId) + .maybeSingle()), + loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase + .from("chart_profiles") + .select("id,user_id,role,profile") + .eq("id", chartProfileId) + .eq("user_id", user.id) + .maybeSingle()), + }); + if (binding) values = { ...values, ...binding }; + } const { data, error } = await supabase .from("chat_sessions") .update(values) @@ -102,6 +139,10 @@ export async function PATCH(request: Request, context: RouteContext) { if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 }); return NextResponse.json({ ok: true }); } catch (error) { + if (error instanceof ConsultationSubjectError) { + const failure = consultationSubjectFailureResponse(error); + return NextResponse.json(failure.body, { status: failure.status }); + } if (error instanceof ChatSessionBodyTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); } diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index fe2bce8b..6b0e5223 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -5,6 +5,13 @@ import { ChatSessionBodyTooLargeError, readChatSessionJson, } from "@/lib/chat-session-write-contract"; +import { + authoritativeSessionBinding, + ConsultationSubjectError, + consultationSubjectFailureResponse, + ownedChartProfileFromResult, + selfNameFromResult, +} from "@/lib/consultation-subject-resolver"; import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { resolveInheritedContextSummary } from "@/lib/session-context-summary"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; @@ -93,6 +100,24 @@ export async function POST(request: Request) { const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request)); if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data; + const binding = await authoritativeSessionBinding({ + userId: user.id, + requested: values, + existingMessages: values.messages, + existingBinding: null, + loadSelfProfile: async (profileUserId) => selfNameFromResult(await supabase + .from("profiles") + .select("name") + .eq("id", profileUserId) + .maybeSingle()), + loadOwnedChartProfile: async (chartProfileId) => ownedChartProfileFromResult(await supabase + .from("chart_profiles") + .select("id,user_id,role,profile") + .eq("id", chartProfileId) + .eq("user_id", user.id) + .maybeSingle()), + }); + const insertValues = binding ? { ...values, ...binding } : values; const inheritedSummary = await resolveInheritedContextSummary({ continuedFromSessionId, loadOwnedSummary: async (sourceId) => { @@ -109,7 +134,7 @@ export async function POST(request: Request) { chatSessionCreateInsertRow({ id, userId: user.id, - values, + values: insertValues, inheritedSummary, updatedAt: new Date().toISOString(), }), @@ -117,6 +142,10 @@ export async function POST(request: Request) { if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 }); return NextResponse.json({ ok: true }, { status: 201 }); } catch (error) { + if (error instanceof ConsultationSubjectError) { + const failure = consultationSubjectFailureResponse(error); + return NextResponse.json(failure.body, { status: failure.status }); + } if (error instanceof ChatSessionBodyTooLargeError) { return NextResponse.json({ error: error.message }, { status: 413 }); } diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index e74156cb..3d14ac2e 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -13,6 +13,12 @@ import { } from "./consultation-birth-time-mode.ts"; import type { GeneralDailyReference } from "./general-daily-panchanga.ts"; import { resolveAyanamsa, type AyanamsaName } from "./ayanamsa.ts"; +import { + ConsultationSubjectError, + resolveConsultationSubject, + type ConsultationSubjectBinding, + type OwnedChartProfile, +} from "./consultation-subject-resolver.ts"; export type ConsultationProfileTruthErrorCode = | "profile_unavailable" @@ -133,6 +139,11 @@ type PrepareConsultationRouteInput = Readonly<{ resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise; beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise; reserve: () => Promise; + subject?: Readonly<{ + binding: ConsultationSubjectBinding; + loadOwnedChartProfile: (chartProfileId: string) => Promise; + clientBirth?: unknown; + }>; }>; type PrepareConsultationRouteWithGuard = Omit< @@ -142,6 +153,12 @@ type PrepareConsultationRouteWithGuard = Omit< beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise; }>; +export type PreparedConsultationSubject = Readonly<{ + role: "self" | "other"; + name: string; + chartProfileId: string | null; +}>; + export type PreparedConsultationRoute = Readonly<{ consultationMode: ConsultationBirthTimeMode; serverChart: ServerChartConsultation | null; @@ -149,6 +166,7 @@ export type PreparedConsultationRoute = Re generalDailyReference: GeneralDailyReference | null; reservation: Reservation; preReserveResult: GuardResult; + subject: PreparedConsultationSubject | null; }>; type RecordValue = Record; @@ -500,10 +518,31 @@ export async function prepareConsultationRoute( input: PrepareConsultationRouteInput | PrepareConsultationRouteWithGuard, ): Promise | undefined>> { let profile: unknown; + let subjectRole: "self" | "other" = "self"; + let subject: PreparedConsultationSubject | null = null; try { - profile = await input.loadProfile(input.userId); + if (input.subject) { + const resolved = await resolveConsultationSubject({ + userId: input.userId, + binding: input.subject.binding, + loadSelfProfile: input.loadProfile, + loadOwnedChartProfile: input.subject.loadOwnedChartProfile, + clientBirth: input.subject.clientBirth, + }); + subjectRole = resolved.role; + subject = { + role: resolved.role, + name: resolved.name, + chartProfileId: resolved.chartProfileId, + }; + if (resolved.profile == null) throw new ConsultationProfileTruthError("profile_unavailable"); + profile = resolved.profile; + } else { + profile = await input.loadProfile(input.userId); + } } catch (error) { - if (input.mode === "general_no_birth_time") profile = null; + if (error instanceof ConsultationSubjectError) throw error; + if (input.mode === "general_no_birth_time" && subjectRole !== "other") profile = null; else if (error instanceof ConsultationProfileTruthError) throw error; else throw new ConsultationProfileTruthError("profile_unavailable"); } @@ -531,6 +570,7 @@ export async function prepareConsultationRoute( if ( consultationMode === "verified_chart" && serverChart.truth.birthTimeStatus === "accepted" + && subjectRole === "self" && input.loadCandidateRange ) { try { @@ -580,5 +620,6 @@ export async function prepareConsultationRoute( generalDailyReference, reservation, preReserveResult, + subject, }); } diff --git a/frontend/src/lib/consultation-subject-resolver.ts b/frontend/src/lib/consultation-subject-resolver.ts new file mode 100644 index 00000000..10122606 --- /dev/null +++ b/frontend/src/lib/consultation-subject-resolver.ts @@ -0,0 +1,384 @@ +/** + * Ordinary-chat subject truth. + * + * `self` is the current user's `profiles` row. `other` is a `chart_profiles` + * row owned by that user. Session name/role snapshots and client birth fields + * are not chart truth. A failed other lookup never falls back to self. + */ + +export const consultationSubjectErrorCodes = [ + "subject_not_found", + "subject_role_mismatch", + "subject_incomplete", + "subject_unavailable", + "subject_locked", +] as const; + +export type ConsultationSubjectErrorCode = (typeof consultationSubjectErrorCodes)[number]; + +export class ConsultationSubjectError extends Error { + readonly code: ConsultationSubjectErrorCode; + + constructor(code: ConsultationSubjectErrorCode) { + super(`Consultation subject rejected: ${code}`); + this.name = "ConsultationSubjectError"; + this.code = code; + } +} + +export type ConsultationSubjectBinding = Readonly<{ + chartProfileId: string | null; + chartProfileRole: "self" | "other" | null; + chartProfileName: string | null; +}>; + +export type OwnedChartProfile = Readonly<{ + id: string; + userId: string; + role: string; + profile: unknown; +}>; + +export type ResolvedConsultationSubject = Readonly<{ + role: "self" | "other"; + chartProfileId: string | null; + name: string; + profile: unknown; +}>; + +export type ConsultationSubjectFailureBody = Readonly<{ + error: string; + message: string; + code: "subject_incomplete" | "subject_unavailable" | "subject_locked"; +}>; + +const bindingKeys = ["chart_profile_id", "chart_profile_name", "chart_profile_role"] as const; + +const emptyBinding: ConsultationSubjectBinding = { + chartProfileId: null, + chartProfileRole: null, + chartProfileName: null, +}; + +function textOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function recordOf(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function pickText(row: Record, keys: readonly string[]): string | null { + for (const key of keys) { + const value = textOrNull(row[key]); + if (value) return value; + } + return null; +} + +function pickNumber(row: Record, keys: readonly string[]): number | null { + for (const key of keys) { + const value = row[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +function profileName(value: unknown): string { + const row = recordOf(value); + const name = row ? pickText(row, ["name"]) : null; + return name ? name.slice(0, 80) : ""; +} + +/** + * Library rows are camelCase `Profile` objects. Consultation truth is the + * snake_case account shape. Client request keys (`year`, `lat`, `city`, …) + * are not read, so they cannot fill gaps. + */ +export function chartProfileToConsultationRow(value: unknown): Record | null { + const row = recordOf(value); + if (!row) return null; + const status = pickText(row, ["birth_time_status", "birthTimeStatus"]); + const verified = status === "accepted" || status === "confirmed"; + const activeTime = verified + ? pickText(row, ["active_birth_time", "time"]) + : pickText(row, ["active_birth_time"]); + return { + name: pickText(row, ["name"]), + birth_date: pickText(row, ["birth_date", "date"]), + active_birth_date: pickText(row, ["active_birth_date", "activeDate"]), + reported_birth_time: pickText(row, ["reported_birth_time", "reportedTime"]), + active_birth_time: activeTime, + birth_time_source: pickText(row, ["birth_time_source", "birthTimeSource"]), + birth_time_period: pickText(row, ["birth_time_period", "birthTimePeriod"]), + declared_window_start: pickText(row, ["declared_window_start", "declaredWindowStart"]), + declared_window_end: pickText(row, ["declared_window_end", "declaredWindowEnd"]), + birth_time_status: status, + country_code: pickText(row, ["country_code", "countryCode"]), + province_code: pickText(row, ["province_code", "provinceCode"]), + city_code: pickText(row, ["city_code", "cityCode"]), + district_code: pickText(row, ["district_code", "districtCode"]), + latitude: pickNumber(row, ["latitude"]), + longitude: pickNumber(row, ["longitude"]), + timezone_offset: pickNumber(row, ["timezone_offset", "timezoneOffset"]), + active_birth_timezone_offset: pickNumber(row, ["active_birth_timezone_offset", "activeTimezoneOffset"]), + birth_place_label: pickText(row, ["birth_place_label", "birthPlaceLabel"]), + birth_place_type: pickText(row, ["birth_place_type", "birthPlaceType"]), + birth_place_provider: pickText(row, ["birth_place_provider", "birthPlaceProvider"]), + birth_place_provider_id: pickText(row, ["birth_place_provider_id", "birthPlaceProviderId"]), + timezone_id: pickText(row, ["timezone_id", "timezoneId"]), + timezone_source: pickText(row, ["timezone_source", "timezoneSource"]), + ayanamsa: pickText(row, ["ayanamsa"]), + }; +} + +function otherProfileComplete(row: Record): boolean { + const name = typeof row.name === "string" && row.name.trim(); + const birthDate = typeof row.birth_date === "string" && row.birth_date.trim(); + const source = typeof row.birth_time_source === "string" && row.birth_time_source.trim(); + const status = typeof row.birth_time_status === "string" && row.birth_time_status.trim(); + const latitude = typeof row.latitude === "number" && Number.isFinite(row.latitude); + const longitude = typeof row.longitude === "number" && Number.isFinite(row.longitude); + const label = typeof row.birth_place_label === "string" && row.birth_place_label.trim(); + const timezoneOffset = typeof row.timezone_offset === "number" && Number.isFinite(row.timezone_offset); + const timezoneId = typeof row.timezone_id === "string" && row.timezone_id.trim(); + return Boolean(name && birthDate && source && status && latitude && longitude && label && (timezoneOffset || timezoneId)); +} + +export function consultationSubjectBindingFromSession(row: { + chart_profile_id?: unknown; + chart_profile_name?: unknown; + chart_profile_role?: unknown; +}): ConsultationSubjectBinding { + const role = row.chart_profile_role === "self" || row.chart_profile_role === "other" + ? row.chart_profile_role + : null; + return { + chartProfileId: textOrNull(row.chart_profile_id), + chartProfileRole: role, + chartProfileName: textOrNull(row.chart_profile_name), + }; +} + +export function sessionHasMessages(messages: unknown): boolean { + if (messages == null) return false; + if (Array.isArray(messages)) return messages.length > 0; + return true; +} + +export function sessionBindingRequested(values: object): boolean { + return bindingKeys.some((key) => Object.prototype.hasOwnProperty.call(values, key)); +} + +export function sameSessionBinding( + left: ConsultationSubjectBinding, + right: ConsultationSubjectBinding, +): boolean { + return textOrNull(left.chartProfileId) === textOrNull(right.chartProfileId) + && (left.chartProfileRole ?? null) === (right.chartProfileRole ?? null) + && textOrNull(left.chartProfileName) === textOrNull(right.chartProfileName); +} + +async function readOwnedChart( + load: (chartProfileId: string) => Promise, + userId: string, + chartProfileId: string, +): Promise { + try { + const row = await load(chartProfileId); + if (row === null) return null; + if (!row || typeof row.id !== "string" || typeof row.userId !== "string" || typeof row.role !== "string") { + throw new ConsultationSubjectError("subject_unavailable"); + } + if (row.userId !== userId) throw new ConsultationSubjectError("subject_not_found"); + return row; + } catch (error) { + if (error instanceof ConsultationSubjectError) throw error; + throw new ConsultationSubjectError("subject_unavailable"); + } +} + +async function resolveOther( + input: { + userId: string; + loadOwnedChartProfile: (chartProfileId: string) => Promise; + }, + chartProfileId: string, +): Promise { + const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, chartProfileId); + if (!row) throw new ConsultationSubjectError("subject_not_found"); + if (row.role !== "other") throw new ConsultationSubjectError("subject_role_mismatch"); + const profile = chartProfileToConsultationRow(row.profile); + if (!profile || !otherProfileComplete(profile)) { + throw new ConsultationSubjectError("subject_incomplete"); + } + return Object.freeze({ + role: "other", + chartProfileId: row.id, + name: profileName(profile), + profile: Object.freeze({ ...profile }), + }); +} + +export async function resolveConsultationSubject(input: { + userId: string; + binding: ConsultationSubjectBinding; + loadSelfProfile: (userId: string) => Promise; + loadOwnedChartProfile: (chartProfileId: string) => Promise; + /** Present so callers can pass the request body. Never read for chart truth. */ + clientBirth?: unknown; +}): Promise { + const id = textOrNull(input.binding.chartProfileId); + const role = input.binding.chartProfileRole ?? null; + const explicitOtherId = Boolean(id && id !== "self"); + + if (role === "other") { + if (!explicitOtherId) throw new ConsultationSubjectError("subject_role_mismatch"); + return resolveOther(input, id as string); + } + + if (explicitOtherId && role !== "self") { + return resolveOther(input, id as string); + } + + if (role === "self" && explicitOtherId) { + const row = await readOwnedChart(input.loadOwnedChartProfile, input.userId, id as string); + if (!row) throw new ConsultationSubjectError("subject_not_found"); + if (row.role !== "self") throw new ConsultationSubjectError("subject_role_mismatch"); + } + + const selfRow = await input.loadSelfProfile(input.userId); + return Object.freeze({ + role: "self", + chartProfileId: "self", + name: profileName(selfRow), + profile: selfRow, + }); +} + +function mergedBinding( + requested: object, + existing: ConsultationSubjectBinding | null, +): ConsultationSubjectBinding { + const record = requested as Record; + const base = existing ?? emptyBinding; + const role = record.chart_profile_role; + return { + chartProfileId: Object.prototype.hasOwnProperty.call(record, "chart_profile_id") + ? textOrNull(record.chart_profile_id) + : base.chartProfileId, + chartProfileRole: Object.prototype.hasOwnProperty.call(record, "chart_profile_role") + ? role === "self" || role === "other" ? role : null + : base.chartProfileRole, + chartProfileName: Object.prototype.hasOwnProperty.call(record, "chart_profile_name") + ? textOrNull(record.chart_profile_name) + : base.chartProfileName, + }; +} + +export async function authoritativeSessionBinding(input: { + userId: string; + requested: object; + existingMessages: unknown; + existingBinding: ConsultationSubjectBinding | null; + loadSelfProfile: (userId: string) => Promise; + loadOwnedChartProfile: (chartProfileId: string) => Promise; +}): Promise<{ + chart_profile_id: string | null; + chart_profile_name: string | null; + chart_profile_role: "self" | "other" | null; +} | null> { + if (!sessionBindingRequested(input.requested)) return null; + const requestedBinding = mergedBinding(input.requested, input.existingBinding); + const existing = input.existingBinding ?? emptyBinding; + if (sessionHasMessages(input.existingMessages)) { + if (!sameSessionBinding(requestedBinding, existing)) { + throw new ConsultationSubjectError("subject_locked"); + } + return { + chart_profile_id: existing.chartProfileId, + chart_profile_name: existing.chartProfileName, + chart_profile_role: existing.chartProfileRole, + }; + } + const resolved = await resolveConsultationSubject({ + userId: input.userId, + binding: requestedBinding, + loadSelfProfile: input.loadSelfProfile, + loadOwnedChartProfile: input.loadOwnedChartProfile, + clientBirth: { + name: requestedBinding.chartProfileName, + }, + }); + return { + chart_profile_id: resolved.role === "self" ? "self" : resolved.chartProfileId, + chart_profile_name: resolved.name || null, + chart_profile_role: resolved.role, + }; +} + +export function ownedChartProfileFromResult(result: { + data: unknown; + error: unknown; +}): OwnedChartProfile | null { + if (result.error) throw new ConsultationSubjectError("subject_unavailable"); + const row = recordOf(result.data); + if (!row) return null; + if (typeof row.id !== "string" || typeof row.user_id !== "string" || typeof row.role !== "string") { + throw new ConsultationSubjectError("subject_unavailable"); + } + return { id: row.id, userId: row.user_id, role: row.role, profile: row.profile }; +} + +export function selfNameFromResult(result: { data: unknown; error: unknown }): unknown { + if (result.error) throw new ConsultationSubjectError("subject_unavailable"); + return result.data; +} + +export function consultationSubjectFailureResponse(error: ConsultationSubjectError): { + status: number; + body: ConsultationSubjectFailureBody; +} { + if (error.code === "subject_locked") { + return { + status: 409, + body: { + error: "不能更换这段对话的人物", + message: "这段对话已经开始。要换一个人,请新建对话。", + code: "subject_locked", + }, + }; + } + if (error.code === "subject_incomplete") { + return { + status: 409, + body: { + error: "这份人物资料还不完整", + message: "请补全出生日期、时间和地点后再发送,本次不会扣点。", + code: "subject_incomplete", + }, + }; + } + if (error.code === "subject_unavailable") { + return { + status: 503, + body: { + error: "暂时无法核对人物资料", + message: "请稍后重试,本次不会扣点。", + code: "subject_unavailable", + }, + }; + } + return { + status: 409, + body: { + error: "无法使用这份人物资料", + message: "这份资料不存在、已删除,或与当前会话不一致。请新建对话后再试,本次不会扣点。", + code: "subject_unavailable", + }, + }; +} diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 3b1a6dc5..6602a954 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -73,7 +73,10 @@ test("standard consultation resolves and settles the session-pinned model versio assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/); // Former value: select("id,model_id,model_config_version,session_type,messages"). // First-round session titles need the current title, theme, and chart role. - assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_role,context_summary"\)/); + // 原值: select includes chart_profile_role but not chart_profile_id or chart_profile_name. + // 新值: the same select also reads chart_profile_id and chart_profile_name. + // 原因: ordinary chat resolves the subject from the stored binding, not from chart_profile_role alone. + assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary"\)/); assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/); assert.match(consultRoute, /actualModelId: selectedModel\.id/); assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/); diff --git a/frontend/tests/consultation-subject-binding.test.ts b/frontend/tests/consultation-subject-binding.test.ts new file mode 100644 index 00000000..6566b356 --- /dev/null +++ b/frontend/tests/consultation-subject-binding.test.ts @@ -0,0 +1,512 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + ConsultationProfileTruthError, + prepareConsultationRoute, +} from "../src/lib/consultation-route-service.ts"; +import { + authoritativeSessionBinding, + ConsultationSubjectError, + consultationSubjectBindingFromSession, + consultationSubjectFailureResponse, + resolveConsultationSubject, + type OwnedChartProfile, +} from "../src/lib/consultation-subject-resolver.ts"; + +const selfProfile = Object.freeze({ + name: "Synthetic Self", + birth_date: "1990-01-02", + reported_birth_time: "08:15:00", + active_birth_time: "08:15:00", + birth_time_source: "family_exact", + birth_time_status: "reported", + birth_place_label: "Synthetic City", + latitude: 31.2, + longitude: 121.5, + timezone_offset: 8, + country_code: "CN", + province_code: "310000", + city_code: "310100", + district_code: "", +}); + +const otherProfile = Object.freeze({ + name: "Synthetic Other", + date: "1988-03-04", + reportedTime: "09:20", + time: "", + birthTimeSource: "family_exact", + birthTimeStatus: "reported", + birthPlaceLabel: "Synthetic Harbor", + latitude: 22.3, + longitude: 114.2, + timezoneOffset: 8, + countryCode: "CN", + ayanamsa: "lahiri", +}); + +const clientBirth = Object.freeze({ + name: "Client Name", + year: 2001, + month: 2, + day: 3, + hour: 4, + minute: 5, + city: "Client City", + lat: 1, + lon: 2, + tz: 3, +}); + +function ownedOther(profile: unknown = otherProfile, userId = "user-self"): OwnedChartProfile { + return { id: "chart-other", userId, role: "other", profile }; +} + +test("self resolves from the authoritative profile and other resolves from the owned chart profile", async () => { + let selfLoads = 0; + let otherLoads = 0; + const self = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "self", chartProfileRole: "self", chartProfileName: "Client Name" }, + clientBirth, + loadSelfProfile: async (userId) => { + assert.equal(userId, "user-self"); + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => { + otherLoads += 1; + return ownedOther(); + }, + }); + assert.equal(selfLoads, 1); + assert.equal(otherLoads, 0); + assert.equal(self.role, "self"); + assert.equal(self.name, "Synthetic Self"); + assert.equal((self.profile as { birth_date: string }).birth_date, "1990-01-02"); + + const legacy = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: null, chartProfileRole: null, chartProfileName: null }, + clientBirth, + loadSelfProfile: async () => selfProfile, + loadOwnedChartProfile: async () => { + otherLoads += 1; + return ownedOther(); + }, + }); + assert.equal(otherLoads, 0); + assert.equal(legacy.role, "self"); + assert.equal((legacy.profile as { birth_date: string }).birth_date, "1990-01-02"); + + selfLoads = 0; + const other = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Stale Label" }, + clientBirth, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async (chartProfileId) => { + assert.equal(chartProfileId, "chart-other"); + return ownedOther(); + }, + }); + assert.equal(selfLoads, 0); + assert.equal(other.role, "other"); + assert.equal(other.name, "Synthetic Other"); + assert.equal(other.chartProfileId, "chart-other"); + const row = other.profile as { birth_date: string; latitude: number; reported_birth_time: string }; + assert.equal(row.birth_date, "1988-03-04"); + assert.equal(row.latitude, 22.3); + assert.equal(row.reported_birth_time, "09:20"); + assert.equal("year" in row, false); + assert.equal("lat" in row, false); +}); + +test("another user's id, a random id, a role mismatch, and a deleted or incomplete profile fail closed", async () => { + const cases: Array<{ name: string; owned: OwnedChartProfile | null | Error; code: string }> = [ + { name: "other user", owned: ownedOther(otherProfile, "user-other"), code: "subject_not_found" }, + { name: "random id", owned: null, code: "subject_not_found" }, + { name: "deleted", owned: null, code: "subject_not_found" }, + { + name: "role mismatch", + owned: { id: "chart-other", userId: "user-self", role: "self", profile: otherProfile }, + code: "subject_role_mismatch", + }, + { + name: "missing", + owned: null, + code: "subject_not_found", + }, + { + name: "incomplete", + owned: ownedOther({ name: "Synthetic Other", year: 2001, lat: 1, city: "Client City" }), + code: "subject_incomplete", + }, + ]; + + for (const item of cases) { + let selfLoads = 0; + const error = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" }, + clientBirth, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => { + if (item.owned instanceof Error) throw item.owned; + return item.owned; + }, + }).then(() => null, (caught: unknown) => caught); + assert.ok(error instanceof ConsultationSubjectError, item.name); + assert.equal(error.code, item.code, item.name); + assert.equal(error.message.includes("Client Name"), false, item.name); + assert.equal(error.message.includes("Synthetic"), false, item.name); + assert.equal(selfLoads, 0, item.name); + } +}); + +test("a forged display name cannot complete or replace the server profile", async () => { + let selfLoads = 0; + const missingName = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" }, + clientBirth, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther({ ...otherProfile, name: "" }), + }).then(() => null, (caught: unknown) => caught); + assert.ok(missingName instanceof ConsultationSubjectError); + assert.equal(missingName.code, "subject_incomplete"); + assert.equal(missingName.message.includes("Client Name"), false); + assert.equal(selfLoads, 0); + + const claimedSelf = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "self", chartProfileName: "Synthetic Self" }, + clientBirth, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther(), + }).then(() => null, (caught: unknown) => caught); + assert.ok(claimedSelf instanceof ConsultationSubjectError); + assert.equal(claimedSelf.code, "subject_role_mismatch"); + assert.equal(selfLoads, 0); +}); + +test("database failures stay closed and do not leak internals", async () => { + const error = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null }, + loadSelfProfile: async () => selfProfile, + loadOwnedChartProfile: async () => { + throw new Error("duplicate key value violates unique constraint for user-other@example"); + }, + }).then(() => null, (caught: unknown) => caught); + assert.ok(error instanceof ConsultationSubjectError); + assert.equal(error.code, "subject_unavailable"); + assert.equal(error.message.includes("duplicate"), false); + assert.equal(error.message.includes("example"), false); + const response = consultationSubjectFailureResponse(error); + const encoded = JSON.stringify(response); + assert.equal(encoded.includes("duplicate"), false); + assert.equal(encoded.includes("user-other"), false); + assert.equal(encoded.includes("@"), false); + assert.match(response.body.message, /不会扣点/); +}); + +test("conflicting client birth fields still lose to the server profile", async () => { + let reserves = 0; + let selfLoads = 0; + const prepared = await prepareConsultationRoute({ + userId: "user-self", + mode: "unverified_birth_time", + subject: { + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Client Name" }, + clientBirth, + loadOwnedChartProfile: async () => ownedOther(), + }, + loadProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + beforeReserve({ serverChart, consultationMode }) { + assert.equal(consultationMode, "unverified_birth_time"); + assert.equal(serverChart?.name, "Synthetic Other"); + assert.equal(serverChart?.toolInput.year, 1988); + assert.equal(serverChart?.toolInput.month, 3); + assert.equal(serverChart?.toolInput.day, 4); + assert.equal(serverChart?.toolInput.hour, 9); + assert.equal(serverChart?.toolInput.minute, 20); + assert.equal(serverChart?.toolInput.city, "Synthetic Harbor"); + assert.equal(serverChart?.toolInput.lat, 22.3); + assert.equal(serverChart?.toolInput.lon, 114.2); + assert.equal(serverChart?.toolInput.tz, 8); + assert.equal(serverChart?.toolInput.ayanamsa, "lahiri"); + }, + reserve: async () => { + reserves += 1; + return { charged: true }; + }, + }); + assert.equal(selfLoads, 0); + assert.equal(reserves, 1); + assert.equal(prepared.subject?.role, "other"); + assert.equal(prepared.subject?.name, "Synthetic Other"); + assert.notEqual(prepared.serverChart?.toolInput.year, clientBirth.year); +}); + +test("consult preparation receives the other profile and a resolver failure does not charge or prepare a model call", async () => { + const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8"); + assert.match(route, /chart_profile_id,chart_profile_name,chart_profile_role/); + assert.match(route, /clientBirth: parsed\.data/); + assert.match(route, /loadOwnedChartProfile/); + assert.match(route, /consultationSubjectBindingFromSession\(chatSession\)/); + assert.doesNotMatch(route, /parsed\.data\.name/); + const failureHandler = route.slice( + route.indexOf("instanceof ConsultationSubjectError"), + route.indexOf("const modelSelection"), + ); + assert.match(failureHandler, /consultationSubjectFailureResponse/); + assert.doesNotMatch(failureHandler, /getJyotishAgent|streamAgentResponse|streamTextResponse/); + + let reserves = 0; + let selfLoads = 0; + let preparations = 0; + const error = await prepareConsultationRoute({ + userId: "user-self", + mode: "unverified_birth_time", + subject: { + binding: consultationSubjectBindingFromSession({ + chart_profile_id: "chart-missing", + chart_profile_role: "other", + chart_profile_name: "Synthetic Other", + }), + clientBirth, + loadOwnedChartProfile: async () => null, + }, + loadProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + beforeReserve() { + preparations += 1; + }, + reserve: async () => { + reserves += 1; + return { charged: true }; + }, + }).then(() => null, (caught: unknown) => caught); + assert.ok(error instanceof ConsultationSubjectError); + assert.equal(error.code, "subject_not_found"); + assert.equal(selfLoads, 0); + assert.equal(preparations, 0); + assert.equal(reserves, 0); + assert.equal(error instanceof ConsultationProfileTruthError, false); +}); + +test("an other chart does not inherit the account candidate range", async () => { + let rangeLoads = 0; + const prepared = await prepareConsultationRoute({ + userId: "user-self", + mode: "verified_chart", + subject: { + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null }, + loadOwnedChartProfile: async () => ownedOther({ + ...otherProfile, + time: "09:20", + birthTimeStatus: "confirmed", + }), + }, + loadProfile: async () => selfProfile, + loadCandidateRange: async () => { + rangeLoads += 1; + return { startTime: "08:00", endTime: "08:30" }; + }, + reserve: async () => "reserved", + }); + assert.equal(rangeLoads, 0); + assert.equal(prepared.serverChart?.toolInput.hour, 9); + assert.equal("candidate_range" in (prepared.serverChart?.toolInput ?? {}), false); +}); + +test("a session with messages rejects rebinding and an empty session stores the server name", async () => { + const stored = consultationSubjectBindingFromSession({ + chart_profile_id: "chart-other", + chart_profile_role: "other", + chart_profile_name: "Synthetic Other", + }); + let selfLoads = 0; + const locked = await authoritativeSessionBinding({ + userId: "user-self", + requested: { + chart_profile_id: "chart-next", + chart_profile_role: "other", + chart_profile_name: "Client Name", + }, + existingMessages: [{ role: "user", text: "synthetic question" }], + existingBinding: stored, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther(), + }).then(() => null, (caught: unknown) => caught); + assert.ok(locked instanceof ConsultationSubjectError); + assert.equal(locked.code, "subject_locked"); + assert.equal(selfLoads, 0); + const lockedResponse = consultationSubjectFailureResponse(locked); + assert.equal(lockedResponse.status, 409); + assert.equal(JSON.stringify(lockedResponse).includes("Client Name"), false); + + const forgedRole = await authoritativeSessionBinding({ + userId: "user-self", + requested: { + chart_profile_id: "chart-other", + chart_profile_role: "self", + chart_profile_name: "Synthetic Self", + }, + existingMessages: [], + existingBinding: null, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther(), + }).then(() => null, (caught: unknown) => caught); + assert.ok(forgedRole instanceof ConsultationSubjectError); + assert.equal(forgedRole.code, "subject_role_mismatch"); + assert.equal(selfLoads, 0); + + const created = await authoritativeSessionBinding({ + userId: "user-self", + requested: { + chart_profile_id: "chart-other", + chart_profile_role: "other", + chart_profile_name: "Client Name", + }, + existingMessages: [], + existingBinding: null, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther(), + }); + assert.equal(selfLoads, 0); + assert.deepEqual(created, { + chart_profile_id: "chart-other", + chart_profile_name: "Synthetic Other", + chart_profile_role: "other", + }); +}); + +test("refresh and deep link keep the stored binding instead of a global active chart", async () => { + const activeChartId = "self"; + const stored = consultationSubjectBindingFromSession({ + chart_profile_id: "chart-other", + chart_profile_name: "Synthetic Other", + chart_profile_role: "other", + }); + assert.notEqual(activeChartId, stored.chartProfileId); + let selfLoads = 0; + const resolved = await resolveConsultationSubject({ + userId: "user-self", + binding: stored, + loadSelfProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + loadOwnedChartProfile: async () => ownedOther(), + }); + assert.equal(selfLoads, 0); + assert.equal((resolved.profile as { birth_date: string }).birth_date, "1988-03-04"); + + const refreshed = await authoritativeSessionBinding({ + userId: "user-self", + requested: { + title: "synthetic title", + chart_profile_id: stored.chartProfileId, + chart_profile_name: stored.chartProfileName, + chart_profile_role: stored.chartProfileRole, + }, + existingMessages: [{ role: "assistant", text: "synthetic reply" }], + existingBinding: stored, + loadSelfProfile: async () => { + throw new Error("refresh must not read profiles"); + }, + loadOwnedChartProfile: async () => { + throw new Error("refresh must not reread chart_profiles"); + }, + }); + assert.equal(refreshed?.chart_profile_id, "chart-other"); + assert.equal(refreshed?.chart_profile_role, "other"); + assert.equal(refreshed?.chart_profile_name, "Synthetic Other"); + + const sessions = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); + const sessionItem = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); + assert.match(sessions, /authoritativeSessionBinding/); + assert.match(sessionItem, /authoritativeSessionBinding/); + assert.match(sessionItem, /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role/); + assert.doesNotMatch(sessionItem, /activeChartId/); + assert.doesNotMatch(sessions, /activeChartId/); +}); + +test("a concurrent delete observed by the send fails closed and does not fall back to self", async () => { + let visible: OwnedChartProfile | null = ownedOther(); + let selfLoads = 0; + let reserves = 0; + let preparations = 0; + const pending = prepareConsultationRoute({ + userId: "user-self", + mode: "unverified_birth_time", + subject: { + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: "Synthetic Other" }, + clientBirth, + loadOwnedChartProfile: async () => { + await Promise.resolve(); + return visible; + }, + }, + loadProfile: async () => { + selfLoads += 1; + return selfProfile; + }, + beforeReserve() { + preparations += 1; + }, + reserve: async () => { + reserves += 1; + return { charged: true }; + }, + }); + visible = null; + const error = await pending.then(() => null, (caught: unknown) => caught); + assert.ok(error instanceof ConsultationSubjectError); + assert.equal(error.code, "subject_not_found"); + assert.equal(selfLoads, 0); + assert.equal(preparations, 0); + assert.equal(reserves, 0); + + const source: { date: string; name: string } = { ...otherProfile }; + const resolved = await resolveConsultationSubject({ + userId: "user-self", + binding: { chartProfileId: "chart-other", chartProfileRole: "other", chartProfileName: null }, + loadSelfProfile: async () => selfProfile, + loadOwnedChartProfile: async () => ownedOther(source), + }); + source.date = "2000-01-01"; + source.name = "Changed After Read"; + assert.equal((resolved.profile as { birth_date?: string }).birth_date, "1988-03-04"); + assert.equal(resolved.name, "Synthetic Other"); +});