From 63e045788623e88f7fd7b44102e649e08f4c1f1c Mon Sep 17 00:00:00 2001 From: mono Date: Fri, 16 Oct 2026 12:15:20 +0000 Subject: [PATCH] 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. --- AGENTS.md | 22 + MODIFIED_FILES.md | 104 +++ README.md | 6 + docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md | 109 +++ docs/hardening/README.md | 63 ++ docs/hardening/SETUP_PREVIEW.md | 56 ++ docs/hardening/VALIDATION.md | 47 + internal/bootstrap/routes.go | 3 + internal/builders/conversation_builder.go | 6 +- internal/handlers/api/customer_handler.go | 25 + internal/models/models.go | 6 + internal/pkg/dto/dto.go | 27 +- .../pkg/dto/request/setup_readiness_req.go | 3 + .../pkg/dto/response/setup_readiness_resp.go | 20 +- internal/pkg/i18nx/locales/en-US.yml | 10 + internal/pkg/i18nx/locales/ru-RU.yml | 11 +- internal/pkg/i18nx/locales/zh-CN.yml | 10 + .../openidentity/external_identity_test.go | 24 + internal/pkg/openidentity/openidentity.go | 38 +- .../repositories/auth_scope_repository.go | 68 ++ .../repositories/channel_config_repository.go | 20 + .../conversation_dispatch_queries.go | 17 + .../customer_identity_repository.go | 14 +- .../repositories/customer_identity_scope.go | 51 ++ internal/services/auth_service.go | 114 +-- .../services/auth_session_security_test.go | 246 +++++ .../channel_policy_concurrency_test.go | 78 ++ internal/services/channel_service.go | 51 +- .../services/conversation_dispatch_service.go | 11 +- ...nversation_human_dispatch_realtime_test.go | 17 +- internal/services/conversation_service.go | 19 +- .../customer_identity_security_test.go | 272 ++++++ internal/services/customer_service.go | 14 +- internal/services/customer_session_service.go | 88 +- internal/services/setup_readiness_service.go | 844 +++++++----------- .../services/setup_readiness_service_test.go | 633 ++++++------- internal/services/ws_auth_security_test.go | 184 ++++ internal/services/ws_connection_manager.go | 13 + internal/services/ws_pump_security_test.go | 104 +++ internal/services/ws_realtime_types.go | 26 +- internal/services/ws_service.go | 153 ++-- internal/services/ws_service_test.go | 52 +- .../services/ws_session_concurrency_test.go | 30 + scripts/audit-customer-identity-scopes.sql | 47 + .../_components/setup-readiness-banner.tsx | 50 +- .../_components/agent-message-editor.tsx | 9 + .../conversations/_components/chat-panel.tsx | 32 +- .../setup/_components/setup-wizard-view.tsx | 136 ++- .../(support)/support/chat/closed/page.tsx | 16 +- web/components/auth-provider.tsx | 22 +- web/components/chat/shared-message-editor.tsx | 293 +++--- .../operator-readiness-indicator.tsx | 67 +- web/components/session-provider.tsx | 86 +- web/components/support-chat/chat-shell.tsx | 65 +- .../support-chat/close-navigation.test.mjs | 9 + .../support-chat/close-navigation.ts | 15 +- .../support-chat/customer-message-editor.tsx | 11 + web/hooks/use-agent-conversation-realtime.ts | 24 +- web/lib/api/auth.ts | 10 +- web/lib/api/client.ts | 24 +- web/lib/api/im-session.test.mjs | 173 ++++ web/lib/api/im.ts | 131 ++- web/lib/api/session-isolation.test.mjs | 115 +++ web/lib/api/setup-readiness.test.mjs | 67 ++ web/lib/api/setup-readiness.ts | 27 +- web/lib/message-editor-drafts.test.mjs | 99 ++ web/lib/message-editor-drafts.ts | 95 ++ web/lib/message-file-identity.test.mjs | 14 + web/lib/message-file-identity.ts | 13 + web/lib/realtime-connection.test.mjs | 39 + web/lib/realtime-connection.ts | 12 +- web/lib/realtime-log-context.test.mjs | 11 + web/lib/realtime-log-context.ts | 9 + web/lib/session-request-guard.test.mjs | 32 + web/lib/session-request-guard.ts | 20 + web/lib/stores/agent-conversations.test.mjs | 127 +++ web/lib/stores/agent-conversations.ts | 113 ++- web/lib/stores/support-chat.test.mjs | 289 ++++++ web/lib/stores/support-chat.ts | 806 +++++++++-------- .../test-support/load-typescript-module.mjs | 28 + web/messages/en-US.json | 84 +- web/messages/en-US.json.license | 2 + web/messages/ru-RU.json | 84 +- web/messages/ru-RU.json.license | 2 + web/messages/zh-CN.json | 84 +- web/messages/zh-CN.json.license | 2 + 86 files changed, 5174 insertions(+), 1929 deletions(-) create mode 100644 MODIFIED_FILES.md create mode 100644 docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md create mode 100644 docs/hardening/README.md create mode 100644 docs/hardening/SETUP_PREVIEW.md create mode 100644 docs/hardening/VALIDATION.md create mode 100644 internal/repositories/auth_scope_repository.go create mode 100644 internal/repositories/channel_config_repository.go create mode 100644 internal/repositories/conversation_dispatch_queries.go create mode 100644 internal/repositories/customer_identity_scope.go create mode 100644 internal/services/auth_session_security_test.go create mode 100644 internal/services/channel_policy_concurrency_test.go create mode 100644 internal/services/customer_identity_security_test.go create mode 100644 internal/services/ws_auth_security_test.go create mode 100644 internal/services/ws_pump_security_test.go create mode 100644 internal/services/ws_session_concurrency_test.go create mode 100644 scripts/audit-customer-identity-scopes.sql create mode 100644 web/lib/api/im-session.test.mjs create mode 100644 web/lib/api/session-isolation.test.mjs create mode 100644 web/lib/api/setup-readiness.test.mjs create mode 100644 web/lib/message-editor-drafts.test.mjs create mode 100644 web/lib/message-editor-drafts.ts create mode 100644 web/lib/message-file-identity.test.mjs create mode 100644 web/lib/message-file-identity.ts create mode 100644 web/lib/realtime-log-context.test.mjs create mode 100644 web/lib/realtime-log-context.ts create mode 100644 web/lib/session-request-guard.test.mjs create mode 100644 web/lib/session-request-guard.ts create mode 100644 web/lib/stores/agent-conversations.test.mjs create mode 100644 web/lib/stores/support-chat.test.mjs create mode 100644 web/lib/test-support/load-typescript-module.mjs create mode 100644 web/messages/en-US.json.license create mode 100644 web/messages/ru-RU.json.license create mode 100644 web/messages/zh-CN.json.license diff --git a/AGENTS.md b/AGENTS.md index 2c9e318..ba0e989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,3 +273,25 @@ Before handing off a change, confirm the applicable items: - Generated files were regenerated from their source and were not manually edited. - Relevant tests/typechecks/lint/build/browser checks were run, and any validation limitation is reported. - `git diff --check` passes and unrelated worktree changes remain untouched. + + +## Independent hardening fork boundaries + +Modified in this independent fork. Preserve the upstream license and existing +notices. Public history begins from a source snapshot, not the upstream Git +history. See docs/hardening/README.md for the source revision and release gate. + +- Treat canonical customer/channel identity, scoped issuer proof, live-session + revalidation and permission-filtered WebSocket delivery as security invariants. + Never restore raw external-ID topic routing or unscoped public identity lookup. +- Old customer tokens and legacy identity reconciliation have explicit upgrade + implications. Do not auto-merge legacy customers or delete history to make a + migration pass; follow docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md. +- Setup repair previews must bind exact named changes to the caller and current + state. Apply requires explicit matching approval and transactional writes. + Never resurrect deleted configuration or silently create working schedules. +- Keep editor drafts, sends and delayed async responses bound to their captured + identity and conversation. Clear only the sent draft revision after success. +- Maintain Russian alongside English and Chinese for every changed UI/API string. +- Keep docs/hardening/VALIDATION.md accurate. Tests authored but not executed are + not passing tests; source parsing is not a build or browser acceptance check. diff --git a/MODIFIED_FILES.md b/MODIFIED_FILES.md new file mode 100644 index 0000000..101ed47 --- /dev/null +++ b/MODIFIED_FILES.md @@ -0,0 +1,104 @@ +# Modified-file notice + +This independent hardening fork modifies the source snapshot from +MonoVerseNS/agent-desk-ng revision +`b06954b0a495389cbb5194e0d388342eabe60636` on 2026-10-16. +Upstream Apache-2.0 licensing and notices are retained. Changes are described in +[the hardening notes](docs/hardening/README.md); their validation limits are +[listed separately](docs/hardening/VALIDATION.md). + +Modified source files carry a change notice. JSON translation catalogs retain +valid JSON and have accompanying `.license` notices. This list identifies the +modified and added files; it does not assert that unexecuted tests have passed. + +## Modified upstream files + +- `AGENTS.md` +- `README.md` +- `internal/bootstrap/routes.go` +- `internal/builders/conversation_builder.go` +- `internal/handlers/api/customer_handler.go` +- `internal/models/models.go` +- `internal/pkg/dto/dto.go` +- `internal/pkg/dto/request/setup_readiness_req.go` +- `internal/pkg/dto/response/setup_readiness_resp.go` +- `internal/pkg/i18nx/locales/en-US.yml` +- `internal/pkg/i18nx/locales/ru-RU.yml` +- `internal/pkg/i18nx/locales/zh-CN.yml` +- `internal/pkg/openidentity/external_identity_test.go` +- `internal/pkg/openidentity/openidentity.go` +- `internal/repositories/customer_identity_repository.go` +- `internal/services/auth_service.go` +- `internal/services/channel_service.go` +- `internal/services/conversation_dispatch_service.go` +- `internal/services/conversation_human_dispatch_realtime_test.go` +- `internal/services/conversation_service.go` +- `internal/services/customer_service.go` +- `internal/services/customer_session_service.go` +- `internal/services/setup_readiness_service.go` +- `internal/services/setup_readiness_service_test.go` +- `internal/services/ws_connection_manager.go` +- `internal/services/ws_realtime_types.go` +- `internal/services/ws_service.go` +- `internal/services/ws_service_test.go` +- `web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx` +- `web/app/(dashboard)/dashboard/conversations/_components/agent-message-editor.tsx` +- `web/app/(dashboard)/dashboard/conversations/_components/chat-panel.tsx` +- `web/app/(dashboard)/dashboard/setup/_components/setup-wizard-view.tsx` +- `web/app/(support)/support/chat/closed/page.tsx` +- `web/components/auth-provider.tsx` +- `web/components/chat/shared-message-editor.tsx` +- `web/components/operator-readiness-indicator.tsx` +- `web/components/session-provider.tsx` +- `web/components/support-chat/chat-shell.tsx` +- `web/components/support-chat/close-navigation.test.mjs` +- `web/components/support-chat/close-navigation.ts` +- `web/components/support-chat/customer-message-editor.tsx` +- `web/hooks/use-agent-conversation-realtime.ts` +- `web/lib/api/auth.ts` +- `web/lib/api/client.ts` +- `web/lib/api/im.ts` +- `web/lib/api/setup-readiness.ts` +- `web/lib/realtime-connection.test.mjs` +- `web/lib/realtime-connection.ts` +- `web/lib/stores/agent-conversations.ts` +- `web/lib/stores/support-chat.ts` +- `web/messages/en-US.json` +- `web/messages/ru-RU.json` +- `web/messages/zh-CN.json` + +## Added files + +- `MODIFIED_FILES.md` +- `docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md` +- `docs/hardening/README.md` +- `docs/hardening/SETUP_PREVIEW.md` +- `docs/hardening/VALIDATION.md` +- `internal/repositories/auth_scope_repository.go` +- `internal/repositories/channel_config_repository.go` +- `internal/repositories/conversation_dispatch_queries.go` +- `internal/repositories/customer_identity_scope.go` +- `internal/services/auth_session_security_test.go` +- `internal/services/channel_policy_concurrency_test.go` +- `internal/services/customer_identity_security_test.go` +- `internal/services/ws_auth_security_test.go` +- `internal/services/ws_pump_security_test.go` +- `internal/services/ws_session_concurrency_test.go` +- `scripts/audit-customer-identity-scopes.sql` +- `web/lib/api/im-session.test.mjs` +- `web/lib/api/session-isolation.test.mjs` +- `web/lib/api/setup-readiness.test.mjs` +- `web/lib/message-editor-drafts.test.mjs` +- `web/lib/message-editor-drafts.ts` +- `web/lib/message-file-identity.test.mjs` +- `web/lib/message-file-identity.ts` +- `web/lib/realtime-log-context.test.mjs` +- `web/lib/realtime-log-context.ts` +- `web/lib/session-request-guard.test.mjs` +- `web/lib/session-request-guard.ts` +- `web/lib/stores/agent-conversations.test.mjs` +- `web/lib/stores/support-chat.test.mjs` +- `web/lib/test-support/load-typescript-module.mjs` +- `web/messages/en-US.json.license` +- `web/messages/ru-RU.json.license` +- `web/messages/zh-CN.json.license` diff --git a/README.md b/README.md index 804edb2..3d6ec5c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # AgentDesk + + +> Independent source snapshot with security and reliability changes. Read the +> [hardening and upgrade notes](docs/hardening/README.md) and +> [actual validation status](docs/hardening/VALIDATION.md) before deployment. + English | [简体中文](README_ZH.md) An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment. diff --git a/docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md b/docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md new file mode 100644 index 0000000..fb43399 --- /dev/null +++ b/docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md @@ -0,0 +1,109 @@ +# Customer identity and live-access upgrade + +These are operator instructions for a reviewed staging rollout, not commands +executed by this change. Back up data and verify the exact schema/engine before +any production upgrade. Existing customer and conversation records are retained. + +## Additive schema and issuer boundaries + +`CustomerIdentity` adds: + +- `identity_scope`: indexed varchar(80), default empty for legacy rows. +- `scoped_key`: nullable varchar(64) with a unique index. Legacy rows remain NULL. + +The normal GORM startup migration adds these definitions. No destructive index +replacement, automatic legacy merge, or data deletion is supplied. Test this +additive change on a backup with the deployment's actual SQLite/MySQL version. + +A public identity scope is `v1::SHA256(trimmed channel code)`. +The server derives it; clients cannot select an issuer or inject this scope. +The unique claim key is SHA-256 of the JSON array containing scope, external +source and trimmed external ID. Use the implementation's +`ScopedCustomerIdentityKey` helper rather than a hand-built delimiter format. +New public registrations insert the claim in the same customer transaction. +A competing claim rolls back rather than creating two authorized customers. + +WebSocket routing uses canonical customer ID and channel record ID. Incoming +subscriptions and outgoing recipients are checked against current ownership. +Raw external IDs are not cross-channel or cross-trust routing keys. + +## Existing sessions and history + +Old customer-session JWTs without the new scope are rejected. The updated client +must exchange a fresh token. Until legacy mappings are explicitly reconciled, +a newly authenticated scoped identity can receive a separate customer record, +not the old account's history. The original records remain available to +appropriately authorized operators. + +Before enabling traffic: + +1. Back up data; stop exchanges/writes for the affected scope during reconciliation. +2. Run the read-only `scripts/audit-customer-identity-scopes.sql` reports against an + approved staging copy after the additive columns exist. Keep report output + private: it contains customer identifiers. +3. Establish channel and issuer ownership using trustworthy host-side evidence. + Legacy `user` rows alone cannot distinguish JWT from HMAC provenance. +4. For a proven unique mapping, review a transaction setting both its scope and + computed claim key while retaining its customer ID and conversation history. + Check all associated conversation channels before making a change. +5. Stop on duplicate, inactive, multi-channel or mixed-trust mappings. Never take + the first row or merge customers solely because external IDs match. If a new + scoped record already exists, reconcile both histories explicitly. +6. Reissue customer tokens and verify authorized history, isolation, and rollback + behavior on the staging copy before resuming traffic. + +A channel reassigned to a different identity issuer needs a new channel code. +Ordinary signing-key rotation intentionally retains the same issuer scope. +Use a distinct host JWT signing secret for each independently trusted channel. +The existing host JWT contract validates the channel's secret and expiry, but +does not require a channel audience claim. Reusing a signing key across unrelated +channels lets the holder assert an identity to either channel; scoped storage +is not a defense against a shared issuer key. HMAC proofs bind the channel code +inside the signed payload. No mandatory JWT audience rollout is introduced here. +Legacy duplicate repair remains an operator-reviewed migration, not an automatic +startup side effect. The nullable unique claim allows dirty legacy rows to +remain isolated without blocking new correctly scoped registrations. + +## Anonymous and authenticated-only channels + +Unsigned exchange issues a new random server-owned anonymous identity. Supplying +an old guest external ID is not proof of ownership. Normal resume and refresh +use the signed customer-session token. Near-expiry, still-valid credentials are +renewed through the rate-limited `POST /api/customer/session_refresh` endpoint, +which authenticates the existing session and never creates an anonymous identity. +A refresh failure is not silently replaced with a fresh guest. Losing or expiring +that credential does not authorize reclaiming history by an external ID. + +An optional boolean `requireSignedIdentity` in a web channel's `configJson` +rejects unsigned exchanges and existing guest sessions server-side. Default +false retains anonymous channels. The SDK's `requireUserToken` is not a substitute +for this policy. + +Use the existing authenticated channel GET and +`POST /api/dashboard/channel/update` flow with `channel.update` permission, +preserving all unrelated configuration and required fields. No dedicated new UI +toggle is provided. Older editors which omit this property preserve a configured +true value; an explicit JSON false is needed to disable it. Null and nonboolean +values are rejected. This source change does not configure a live channel. + +## Staff and customer live-session revocation + +Staff sockets bind their original login-session ID and absolute expiry, without +serializing that internal metadata into API responses. Fresh session, account, +role and permission state is checked for incoming operations, event fanout, +actual queued writes and idle checks. Scope changes terminate the connection; +reconnection must obtain fresh authority. Idle checks run every five seconds; +data operations revalidate immediately rather than using a five-second cache. + +Notification sockets cannot subscribe to conversation topics. Dashboard +conversation sockets require conversation-view permission. Customer connections +recheck token expiry, channel policy, customer state and exact identity mapping. + +This implementation deliberately makes database reads at authorization +boundaries. Measure the cost under realistic fanout; any later caching must have +complete revocation invalidation rather than silently reintroducing stale access. + +Channel configuration updates and token-secret resets compare the previously read +configuration bytes and update timestamp before writing. A stale writer receives +a conflict instead of silently overwriting a newly enabled signed-identity policy. +Reload the channel and reapply the intended change after reviewing that conflict. diff --git a/docs/hardening/README.md b/docs/hardening/README.md new file mode 100644 index 0000000..6e99302 --- /dev/null +++ b/docs/hardening/README.md @@ -0,0 +1,63 @@ +# Security and reliability hardening + +This independent fork starts from the current source tree of +[MonoVerseNS/agent-desk-ng](https://github.com/MonoVerseNS/agent-desk-ng), revision +`b06954b0a495389cbb5194e0d388342eabe60636`. It does not import the old Git history. +The upstream Apache-2.0 license and notices remain applicable. This document +records changes made by this fork; it is not a claim of production certification. + +## Release gate + +The hardening changes must be reviewed and validated before deployment. Source +inspection, a compiler check, a passing isolated test, and live acceptance are +separate kinds of evidence. See `VALIDATION.md` for the actual checks performed. +No production service, database, credentials, or external deployment is changed +by committing this source code. + +## Changes being integrated + +- Separate guest, signed-user, issuer and channel identities. Use canonical + customer/channel WebSocket topics and recheck recipients instead of treating + a topic subscription as authorization. +- Apply conversation-view permission to staff WebSockets; keep notifications + separate. Revalidate session expiry, revocation, account state and permissions + on long-lived connections. Disabled roles must no longer grant permissions. +- Keep drafts and asynchronous responses bound to their identity and dialogue. + Preserve drafts on failed sends, protect newly typed revisions, and prevent + duplicate concurrent sends. Profile refresh and delayed API errors must not + restore or expire the wrong signed-in account. +- Turn setup repair into an explicit preview/apply operation. Never resurrect + deleted configuration or silently introduce continuous working hours. Apply + reviewed writes transactionally and dispatch only after commit. +- Make empty setup, initial retry and standalone chat dismissal truthful and + recoverable; remove bearer-token-bearing WebSocket URLs from browser logs. + +## Upgrade precautions + +Customer identity scoping is an additive schema and token-contract change, not +only a frontend patch. Existing unscoped customer tokens will be rejected. +Legacy identities and conversations must be inventoried and reconciled with +trusted issuer/channel provenance before expecting their old history in new +customer sessions. No automatic customer merge or destructive cleanup is +appropriate. See `CUSTOMER_IDENTITY_UPGRADE.md` before any deployment. + +New anonymous sessions are server-issued. Knowing an old anonymous external ID +is not sufficient to reclaim its history. Preserve the signed customer-session +credential for normal resume; loss or expiry can require a new anonymous session. + +The setup quick-fix API now requires preview and explicit apply. Older callers +must not treat a successful preview response as proof that configuration changed. +See [the setup contract](SETUP_PREVIEW.md) before integrating custom callers. + +## Verification priorities + +1. Guest/user/channel collisions, unauthorized staff topics, revoked/expired + sockets, disabled roles and concurrent identity registration. +2. Wrong-recipient drafts, failed and duplicate sends, delayed responses after + switching conversations or accounts, and logout during profile refresh. +3. Preview/apply tampering, permission denial, rollback, deleted configuration, + off-hours schedules, empty-channel simulation and dispatch failure reporting. +4. Desktop and narrow-screen interaction checks in Russian, English and Chinese. + +Use temporary, isolated data for checks. Do not run migration/repair commands +against a live database merely to validate the code. diff --git a/docs/hardening/SETUP_PREVIEW.md b/docs/hardening/SETUP_PREVIEW.md new file mode 100644 index 0000000..aea111e --- /dev/null +++ b/docs/hardening/SETUP_PREVIEW.md @@ -0,0 +1,56 @@ +# Reviewed setup repairs + +The existing authenticated `POST /api/dashboard/setup/quick_fix` endpoint now uses a two-phase contract. It does not write configuration unless the caller explicitly applies a current preview. + +## Contract + +1. Request a preview with `action` and, for an existing agent/channel/team, its `entity_id`. Omit `apply` or set it to `false`. +2. Review `changes`. Each entry names the entity, operation, and exact operational fields before and after. New linked entities are identified by their proposed names until IDs are assigned. Audit timestamps and acting-user metadata are maintained automatically. +3. Submit the same action and target with `apply: true` and the returned `preview_token`. + +The response includes: + +- `applied`: whether an explicit apply request completed. An empty `applied_actions` array means no configuration writes were needed. +- `changes`: the reviewed plan; an empty array is a valid no-op, not a claim that setup is complete. +- `preview_token`: a fingerprint of the acting user, action, target, proposed changes, and current setup configuration. +- `applied_actions`: committed operation/entity identifiers, empty on preview. +- `warning_codes`: manual follow-up requirements or post-commit failures. +- `readiness`: the refreshed diagnostic, omitted if refreshing failed. + +Old callers that omit `apply` receive a read-only preview. Callers must not interpret any HTTP success as a successful mutation. There is deliberately no compatibility path that silently applies an unreviewed repair. + +## Authorization and freshness + +The handler authenticates every request. Setup checks the principal's resolved permission list, matching the normal authorization service. An `admin` or `super_admin` role name alone does not bypass missing permissions. Every planned create or field update also checks its specific permission. + +Apply rebuilds the plan inside one transaction and verifies the fingerprint before writing. A stale preview is rejected and must be reviewed again. Transactional reads use separate GORM statements, preserving row locks without leaking a prior query's table or filters. Updates also compare the target's previous update timestamp. The fingerprint is a freshness/intent check, not an authentication credential or signed authorization grant. + +## Deliberately narrow behavior + +- Broad setup only creates missing defaults. It never rewrites existing configuration, moves existing operators to another team, expands an agent's team pool, or rebinds existing channel routes. +- Deleted records are not update candidates and are never restored. Disabled records and zero-rollout settings are preserved. A targeted “enable my profile” action can enable the authenticated user's non-deleted profile only when the preview explicitly shows the change and the required permissions are present. +- A targeted handoff change affects only the named eligible agent. Missing teams, route choices, disabled configurations, and rollout changes must be chosen in their existing editors. +- Quick fixes never create or modify shifts. Missing schedules and off-hours schedules are distinct diagnostics. Working hours must be entered explicitly in the existing schedule editor. +- A newly created default agent is unpublished. Setup does not publish agents. A default channel can be created only after an eligible agent has a published revision, matching the normal channel editor's publication guard. Readiness reports unpublished agents as incomplete. + +Therefore a broad repair is not a promise of a production-ready routing path. Publishing, deliberate routing choices, and explicit schedules may still be required. + +## Transactions and dispatch + +All configuration writes in an apply operation share one transaction. A failed write rolls back the entire repair. Pending dispatch begins only after the configuration transaction commits. + +A dispatch failure produces `dispatch_failed`; it does not undo committed changes or return an error implying that the configuration write should be retried. A diagnostic refresh failure similarly produces `readiness_refresh_failed`. The UI displays these warnings and requires a fresh preview for a subsequent repair. + +The pending-queue query now reports database read errors and applies its existing batch limit in SQL. Its existing newest-first priority and periodic retry semantics are unchanged. This repair does not redesign queue fairness. + +## Simulation + +The simulator is read-only and sends no customer message. `candidate_operators` is always an array, including missing-channel responses; the client also normalizes legacy null values. + +`bypass_ai: true` is explicitly rejected because the service does not implement AI bypass. AI-only paths remain blocked. AI-first/unknown service modes and partial channel rollout are reported as unverified, never as successful operator delivery. Only an eligible, published, full-channel-rollout, human-only route can produce a positive current-eligibility result. That result is a point-in-time diagnostic, not a delivery guarantee or an end-to-end AI execution test. + +## Validation status + +Focused regression tests cover preview-only behavior, explicit apply, stale previews, atomic rollback, deleted/disabled/zero-rollout preservation, named-target scope, off-hours versus missing schedules, role-name permission bypass, status-only permissions, empty arrays, simulation limitations, publication requirements, and post-commit failures. + +At implementation handoff these tests have been written but not executed. Go compilation, formatting, dependency installation, application execution, database integration, and browser verification were not performed in this source-review environment. TypeScript source parsing, locale-key parity, and whitespace/diff checks have been performed. Those source checks are not substitutes for the deferred tests. diff --git a/docs/hardening/VALIDATION.md b/docs/hardening/VALIDATION.md new file mode 100644 index 0000000..ef2affe --- /dev/null +++ b/docs/hardening/VALIDATION.md @@ -0,0 +1,47 @@ +# Validation status: review branch, not a release + +This source change is published for review. Do not deploy it as a verified +security release until the checks below have been completed. + +## Completed source checks + +- Independent source review of customer/issuer isolation, staff permissions, + long-lived authorization, channel policy concurrency and session refresh. +- Cross-review of editor draft isolation and customer chat lifecycle boundaries. +- Setup preview/apply permission, transaction and target review. +- Syntax parsing of changed TypeScript/TSX and JavaScript regression sources. +- JSON/YAML parsing, matching new locale keys, and Git whitespace checks. + +These checks are not a replacement for compilation or executing the tests. + +## Not yet validated + +- Go formatting and compilation: the Go toolchain was unavailable during this + source-review pass. Run gofmt on all changed Go files before merging. +- Automated regression tests: authored but not executed. +- Full frontend typecheck, lint and build with declared dependencies: not run. + A limited attempt using unrelated available dependencies was incomplete due + to missing project packages; it is not a successful project typecheck. +- Race detector, actual MySQL CAS/unique-index behavior, and staging additive + schema upgrade/reconciliation: not run. +- Application/browser acceptance on desktop and narrow screens: not run. +- Production migration, service startup and deployment: not performed. + +## Checks required before merge or release + +Use the repository's declared Go and frontend package versions, isolated temporary +data, and mocked/loopback external services. Inspect test initialization first; +never point regression tests at a live database or real accounts. + +1. Format all changed Go files and compile the relevant packages. +2. Run openidentity and backend security/setup tests, including concurrent identity + registration, the actual WebSocket pumps, channel CAS and transaction rollback. + Then run the package suites and race checks where supported. +3. Run the frontend typecheck, lint and build with installed locked dependencies. +4. Run the focused Node tests for customer sessions/store/realtime, message drafts, + account isolation, setup preview and dismissal navigation. +5. Validate SQLite and MySQL schema upgrades and uniqueness/CAS behavior on backups. +6. Exercise the modified UI in Russian, English and Chinese, including failures, + account/conversation switches, pending sends and expired sessions. + +Do not treat an unexecuted regression test as proof that a defect is fixed. diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 82b8cd5..778fe16 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package bootstrap import ( @@ -87,6 +89,7 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) { func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) { group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange) + group.POST("/session_refresh", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_refresh) } func registerApiConversationRoutes(group *gin.RouterGroup) { diff --git a/internal/builders/conversation_builder.go b/internal/builders/conversation_builder.go index 00fb78f..fcbb879 100644 --- a/internal/builders/conversation_builder.go +++ b/internal/builders/conversation_builder.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package builders import ( @@ -45,9 +47,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo ClosedBy: item.ClosedBy, CloseReason: item.CloseReason, } - if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil { - ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID) - } + ret.CustomerOnline = services.WsService.IsCustomerOnline(item.CustomerID, item.ChannelID) if item.CurrentAssigneeID > 0 { if user := services.UserService.Get(item.CurrentAssigneeID); user != nil { ret.CurrentAssigneeName = user.Nickname diff --git a/internal/handlers/api/customer_handler.go b/internal/handlers/api/customer_handler.go index d8c510d..0be6f20 100644 --- a/internal/handlers/api/customer_handler.go +++ b/internal/handlers/api/customer_handler.go @@ -1,7 +1,13 @@ +// Modified in the independent hardening fork; see docs/hardening. + package api import ( + "time" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/openidentity" "agent-desk/internal/services" @@ -21,6 +27,7 @@ func CustomerPostSession_exchange(ctx *gin.Context) { IdentityHMAC: identity.Secret, IdentityMaxAge: identity.MaxAge(), ChannelID: channel.ChannelID, + RequireSignedIdentity: services.ChannelService.RequiresSignedIdentity(channel), }) if err != nil { httpx.WriteJSON(ctx, err) @@ -33,3 +40,21 @@ func CustomerPostSession_exchange(ctx *gin.Context) { } httpx.WriteJSON(ctx, resp) } + +// Refresh requires possession of the still-valid customer credential; unlike +// unsigned session exchange it never creates or changes an anonymous identity. +func CustomerPostSession_refresh(ctx *gin.Context) { + channel := services.ChannelService.GetEnabledChannel(ctx) + if channel == nil { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209")); return } + result, err := services.CustomerSessionService.VerifyRequest(ctx, channel) + if err != nil { httpx.WriteJSON(ctx, err); return } + services.CustomerSessionService.SetRefreshHeaders(ctx, result) + source := "guest:" + if result.ExternalUser.ExternalSource == enums.ExternalSourceUser { source = "user:" } + httpx.WriteJSON(ctx, &response.CustomerSessionExchangeResponse{ + CustomerSessionToken: result.Token, + ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339), + IdentityKey: source + result.ExternalUser.ExternalID, + Customer: response.CustomerSessionCustomerResponse{ID: result.CustomerID, Name: result.ExternalUser.ExternalName}, + }) +} diff --git a/internal/models/models.go b/internal/models/models.go index 7a89df6..19392bf 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package models import ( @@ -250,6 +252,10 @@ type Customer struct { // CustomerIdentity 客户第三方身份映射表。 type CustomerIdentity struct { + // Empty scope is legacy/internal integration data; public sessions never fall back to it. + IdentityScope string `gorm:"type:varchar(80);not null;default:'';index:idx_customer_identity_scope"` + // Nullable for legacy rows; all new public scoped identities claim a unique digest. + ScopedKey *string `gorm:"type:varchar(64);uniqueIndex:uk_customer_scoped_key"` ID int64 `gorm:"primaryKey;autoIncrement"` CustomerID int64 `gorm:"type:bigint;not null;uniqueIndex:uk_customer_external"` // 为所属客户ID。 ExternalSource enums.ExternalSource `gorm:"type:varchar(30);uniqueIndex:uk_customer_external"` // 为外部身份来源 diff --git a/internal/pkg/dto/dto.go b/internal/pkg/dto/dto.go index add8ce2..42021ec 100644 --- a/internal/pkg/dto/dto.go +++ b/internal/pkg/dto/dto.go @@ -1,6 +1,11 @@ +// Modified in the independent hardening fork; see docs/hardening. + package dto -import "agent-desk/internal/pkg/enums" +import ( + "agent-desk/internal/pkg/enums" + "time" +) type AuthPrincipal struct { UserID int64 @@ -11,16 +16,22 @@ type AuthPrincipal struct { Status enums.Status Roles []string Permissions []string + + // Session metadata stays internal and binds long-lived connections to the + // original login session and its absolute deadline. + LoginSessionID int64 `json:"-"` + ExpiresAt time.Time `json:"-"` } type WebChannelConfig struct { - Title string `json:"title"` - Subtitle string `json:"subtitle"` - ThemeColor string `json:"themeColor"` - Position string `json:"position"` - Width string `json:"width"` - UserTokenSecret string `json:"userTokenSecret,omitempty"` - PromptStarters []string `json:"promptStarters,omitempty"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + ThemeColor string `json:"themeColor"` + Position string `json:"position"` + Width string `json:"width"` + UserTokenSecret string `json:"userTokenSecret,omitempty"` + RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"` + PromptStarters []string `json:"promptStarters,omitempty"` } type TelegramChannelConfig struct { diff --git a/internal/pkg/dto/request/setup_readiness_req.go b/internal/pkg/dto/request/setup_readiness_req.go index b9aac21..38810a8 100644 --- a/internal/pkg/dto/request/setup_readiness_req.go +++ b/internal/pkg/dto/request/setup_readiness_req.go @@ -1,3 +1,4 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package request type SetupSimulateRouteRequest struct { @@ -8,4 +9,6 @@ type SetupSimulateRouteRequest struct { type SetupQuickFixRequest struct { Action string `json:"action" form:"action"` EntityID int64 `json:"entity_id" form:"entity_id"` + Apply bool `json:"apply" form:"apply"` + PreviewToken string `json:"preview_token" form:"preview_token"` } diff --git a/internal/pkg/dto/response/setup_readiness_resp.go b/internal/pkg/dto/response/setup_readiness_resp.go index 167193b..445d264 100644 --- a/internal/pkg/dto/response/setup_readiness_resp.go +++ b/internal/pkg/dto/response/setup_readiness_resp.go @@ -1,3 +1,4 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package response type SetupIssueItem struct { @@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct { CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"` } -type SetupQuickFixResponse struct { - AppliedActions []string `json:"applied_actions"` - Readiness SetupReadinessResponse `json:"readiness"` +type SetupQuickFixChange struct { + EntityType string `json:"entity_type"` + EntityID int64 `json:"entity_id"` + EntityName string `json:"entity_name"` + Operation string `json:"operation"` + Before map[string]any `json:"before"` + After map[string]any `json:"after"` +} + +type SetupQuickFixResponse struct { + Applied bool `json:"applied"` + PreviewToken string `json:"preview_token"` + Changes []SetupQuickFixChange `json:"changes"` + WarningCodes []string `json:"warning_codes"` + AppliedActions []string `json:"applied_actions"` + Readiness *SetupReadinessResponse `json:"readiness,omitempty"` } diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 5f304d6..7ca2e94 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -1,3 +1,4 @@ +# Modified in the independent hardening fork; upstream notices retained. error.e0001: "AI Agent not found" error.e0002: "AI Agent not found." error.e0003: "AI Agent not found or disabled." @@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "The user's expected outcom tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional." error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v" error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes" + +error.setup.previewRequired: "Review a setup preview and explicitly confirm its token before applying changes." +error.setup.previewChanged: "Configuration changed since the preview. Review a new preview before applying." +error.setup.targetRequired: "Select a specific existing record, or use its settings to choose the intended configuration." +error.setup.targetUnavailable: "The selected record is unavailable, deleted or ineligible. It was not restored." +error.setup.persistenceFailed: "Setup configuration could not be read or saved. No incomplete repair was committed." +error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately." +error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again." +error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again." diff --git a/internal/pkg/i18nx/locales/ru-RU.yml b/internal/pkg/i18nx/locales/ru-RU.yml index cb4e865..6821688 100644 --- a/internal/pkg/i18nx/locales/ru-RU.yml +++ b/internal/pkg/i18nx/locales/ru-RU.yml @@ -1,3 +1,4 @@ +# Modified in the independent hardening fork; upstream notices retained. error.e0001: "ИИ-агент не найден" error.e0002: "ИИ-агент не найден." error.e0003: "ИИ-агент не найден или отключён." @@ -566,4 +567,12 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя." tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно." error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v" -error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки" \ No newline at end of file +error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки" +error.setup.previewRequired: "Перед применением изменений просмотрите план и явно подтвердите его токен." +error.setup.previewChanged: "После предварительного просмотра настройки изменились. Просмотрите новый план." +error.setup.targetRequired: "Выберите конкретную запись или задайте нужную конфигурацию в её настройках." +error.setup.targetUnavailable: "Выбранная запись недоступна, удалена или не подходит. Она не была восстановлена." +error.setup.persistenceFailed: "Не удалось прочитать или сохранить настройки. Неполное исправление не было сохранено." +error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно." +error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением." +error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 6428084..31db76c 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -1,3 +1,4 @@ +# Modified in the independent hardening fork; upstream notices retained. error.e0001: "AI Agent not found" error.e0002: "AI Agent 不存在" error.e0003: "AI Agent 不存在或已停用" @@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结 tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。" error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v" error.setup.authRequired: "请先登录以执行配置诊断或一键修复" + +error.setup.previewRequired: "应用更改前,请先预览设置并明确确认预览令牌。" +error.setup.previewChanged: "预览后配置已更改。请重新预览后再应用。" +error.setup.targetRequired: "请选择具体记录,或在对应设置中选择所需配置。" +error.setup.targetUnavailable: "所选记录不可用、已删除或不符合条件,未进行恢复。" +error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不完整的修复。" +error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。" +error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。" +error.channel.configUpdateFailed: "无法保存渠道配置,请重试。" diff --git a/internal/pkg/openidentity/external_identity_test.go b/internal/pkg/openidentity/external_identity_test.go index a257d73..4d81948 100644 --- a/internal/pkg/openidentity/external_identity_test.go +++ b/internal/pkg/openidentity/external_identity_test.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package openidentity import ( @@ -269,3 +271,25 @@ func flipLastChar(signature string) string { func itoa(value int64) string { return strconv.FormatInt(value, 10) } + +func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) { + for _, signature := range []string{"", "invalid"} { + ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature}) + if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil { t.Fatal("signed-only policy accepted an unsigned or invalid identity") } + } + ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret,testChannelID,testUserID,time.Now())) + user, err := GetExternalUser(ctx, Secrets{ChannelID:testChannelID,IdentityHMAC:testSecret,RequireSignedIdentity:true}) + if err != nil || user.IdentityScope != IdentityScope(testChannelID,"hmac") { t.Fatal("valid signed identity did not retain its HMAC scope") } +} + +func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) { + seen := map[string]bool{} + for _, channel := range []string{"channel-one","channel-two"} { + for _, issuer := range []string{"guest","hmac","jwt"} { + scope := IdentityScope(channel,issuer) + if scope == "" || seen[scope] { t.Fatal("identity scopes collided") } + seen[scope] = true + } + } + if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" { t.Fatal("invalid scope inputs accepted") } +} diff --git a/internal/pkg/openidentity/openidentity.go b/internal/pkg/openidentity/openidentity.go index 3352660..e90c73a 100644 --- a/internal/pkg/openidentity/openidentity.go +++ b/internal/pkg/openidentity/openidentity.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package openidentity import ( @@ -24,6 +26,9 @@ type ExternalUser struct { ExternalSource enums.ExternalSource `json:"externalSource"` ExternalID string `json:"externalId"` ExternalName string `json:"externalName"` + // IdentityScope and ChannelID are server-established authorization context, never request fields. + IdentityScope string `json:"-"` + ChannelID int64 `json:"-"` } type UserTokenClaims struct { @@ -47,6 +52,7 @@ type Secrets struct { // ChannelID is bound into the signed payload so a signature obtained for one // channel cannot be replayed on another. ChannelID string + RequireSignedIdentity bool } // DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable @@ -65,9 +71,8 @@ const identityFutureSkewTolerance = 5 * time.Minute // // issuedAt is unix seconds. Lower-case hex output. Three details are deliberate: // -// - The channel id is included because CustomerIdentity is keyed by -// (source, externalId) globally, so without it a signature minted for one -// channel would assert the same identity on every other channel. +// - The channel id is included so signatures cannot be replayed on a +// different channel, even when a deployment shares an HMAC signing key. // - The timestamp is inside the signed bytes even though it also travels as its // own field, so it cannot be re-dated without breaking the signature. This // bounds how long a leaked pair stays usable; it is not a defence against key @@ -126,6 +131,7 @@ func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) { ExternalSource: enums.ExternalSourceUser, ExternalID: claims.UserID, ExternalName: claims.Name, + IdentityScope: IdentityScope(secrets.ChannelID, "jwt"), }, nil } return getExternalIDUser(ctx, secrets) @@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) ExternalSource: enums.ExternalSourceUser, ExternalID: externalID, ExternalName: externalName, + IdentityScope: IdentityScope(secrets.ChannelID, "hmac"), }, nil } + if secrets.RequireSignedIdentity { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } return &ExternalUser{ ExternalSource: enums.ExternalSourceGuest, ExternalID: externalID, ExternalName: externalName, + IdentityScope: IdentityScope(secrets.ChannelID, "guest"), }, nil } @@ -267,3 +278,24 @@ func decodeExternalDisplayName(s string) string { } return strings.TrimSpace(dec) } + +// IdentityScope separates independent identity issuers, trust levels and channels. +// It deliberately does not depend on a secret so ordinary key rotation preserves +// the issuer's history. Reassigning a channel to a different issuer requires a new +// channel, not silently reusing that issuer's namespace. +func IdentityScope(channelCode, issuer string) string { + if strings.TrimSpace(channelCode) == "" { return "" } + switch issuer { case "jwt", "hmac", "guest": default: return "" } + digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode))) + return "v1:" + issuer + ":" + hex.EncodeToString(digest[:]) +} + +func ValidIdentityScope(channelCode string, external ExternalUser) bool { + if external.ExternalSource == enums.ExternalSourceGuest { + return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest") + } + if external.ExternalSource == enums.ExternalSourceUser { + return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac")) + } + return false +} diff --git a/internal/repositories/auth_scope_repository.go b/internal/repositories/auth_scope_repository.go new file mode 100644 index 0000000..282412f --- /dev/null +++ b/internal/repositories/auth_scope_repository.go @@ -0,0 +1,68 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "time" + + "gorm.io/gorm" +) + +// These handwritten queries supplement the generated CRUD repositories. +func (r *roleRepository) FindEnabledByUser(db *gorm.DB, userID int64) ([]models.Role, error) { + roles := make([]models.Role, 0) + err := db.Table("t_role AS r"). + Select("r.*"). + Joins("JOIN t_user_role AS ur ON ur.role_id = r.id"). + Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk). + Order("r.sort_no ASC, r.id ASC"). + Scan(&roles).Error + return roles, err +} + +func (r *permissionRepository) FindEnabledCodesByUserRoles(db *gorm.DB, userID int64) ([]string, error) { + codes := make([]string, 0) + err := db.Table("t_permission AS p"). + Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id"). + Joins("JOIN t_role AS r ON r.id = rp.role_id"). + Joins("JOIN t_user_role AS ur ON ur.role_id = r.id"). + Where("ur.user_id = ? AND r.status = ? AND p.status = ?", userID, enums.StatusOk, enums.StatusOk). + Distinct().Pluck("p.code", &codes).Error + return codes, err +} + +type UserPermissionOverride struct { + Code string + Effect int +} + +func (r *userPermissionRepository) FindActiveEnabledOverrides(db *gorm.DB, userID int64, now time.Time) ([]UserPermissionOverride, error) { + overrides := make([]UserPermissionOverride, 0) + err := db.Table("t_user_permission AS up"). + Select("p.code, up.effect"). + Joins("JOIN t_permission AS p ON p.id = up.permission_id"). + Where("up.user_id = ? AND p.status = ?", userID, enums.StatusOk). + Where("up.expired_at IS NULL OR up.expired_at > ?", now). + Scan(&overrides).Error + return overrides, err +} + +// GetAuthState deliberately excludes the bearer token; revalidation only needs +// the identity binding, revocation status, and absolute expiry. +func (r *loginSessionRepository) GetAuthState(db *gorm.DB, sessionID, userID int64) (*models.LoginSession, error) { + session := &models.LoginSession{} + if err := db.Select("id", "user_id", "expired_at", "revoked_at"). + Take(session, "id = ? AND user_id = ?", sessionID, userID).Error; err != nil { + return nil, err + } + return session, nil +} + +func (r *userRepository) GetAuthState(db *gorm.DB, userID int64) (*models.User, error) { + user := &models.User{} + if err := db.Select("id", "username", "nickname", "avatar", "user_type", "status", "deleted_at"). + Take(user, "id = ?", userID).Error; err != nil { + return nil, err + } + return user, nil +} diff --git a/internal/repositories/channel_config_repository.go b/internal/repositories/channel_config_repository.go new file mode 100644 index 0000000..975bbfd --- /dev/null +++ b/internal/repositories/channel_config_repository.go @@ -0,0 +1,20 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "gorm.io/gorm" +) + +// UpdatesConfigIfUnchanged prevents a stale read/modify/write from replacing a +// concurrently tightened authentication policy or rotated channel secret. HEX is +// available in SQLite and MySQL and compares exact bytes even when MySQL's text +// column uses a case-insensitive or trailing-space-insensitive collation. +func (r *channelRepository) UpdatesConfigIfUnchanged(db *gorm.DB, expected *models.Channel, columns map[string]any) (bool, error) { + if expected == nil || expected.ID <= 0 { return false, nil } + result := db.Model(&models.Channel{}). + Where("id = ? AND status <> ? AND updated_at = ?", expected.ID, enums.StatusDeleted, expected.UpdatedAt). + Where("HEX(COALESCE(config_json, '')) = HEX(?)", expected.ConfigJSON). + Updates(columns) + return result.RowsAffected == 1, result.Error +} diff --git a/internal/repositories/conversation_dispatch_queries.go b/internal/repositories/conversation_dispatch_queries.go new file mode 100644 index 0000000..8a9b848 --- /dev/null +++ b/internal/repositories/conversation_dispatch_queries.go @@ -0,0 +1,17 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "gorm.io/gorm" +) + +// FindPendingDispatchBatch returns a bounded, checked page. A failed read must +// not be mistaken for an empty queue by setup or the periodic dispatcher. +func (r *conversationRepository) FindPendingDispatchBatch(db *gorm.DB, limit int) ([]models.Conversation, error) { + conversations := make([]models.Conversation, 0) + err := db.Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusPending, 0). + Order("id DESC").Limit(limit).Find(&conversations).Error + return conversations, err +} diff --git a/internal/repositories/customer_identity_repository.go b/internal/repositories/customer_identity_repository.go index 1a032eb..b030a22 100644 --- a/internal/repositories/customer_identity_repository.go +++ b/internal/repositories/customer_identity_repository.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package repositories import ( @@ -6,7 +8,6 @@ import ( "agent-desk/internal/pkg/httpx/params" - "github.com/mlogclub/simple/common/strs" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" ) @@ -36,14 +37,11 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo return ret } -// GetBy 按外部来源 + 外部用户标识查询身份映射(与 uk_customer_external 一致)。 +// GetBy is the legacy/internal-integration lookup. Ambiguous mappings fail closed. func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity { - if strs.IsAnyBlank(string(externalSource), externalID) { - return nil - } - return r.FindOne(db, sqls.NewCnd(). - Eq("external_source", externalSource). - Eq("external_id", externalID)) + identity, err := r.GetUniqueBy(db, "", externalSource, externalID) + if err != nil { return nil } + return identity } func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity { diff --git a/internal/repositories/customer_identity_scope.go b/internal/repositories/customer_identity_scope.go new file mode 100644 index 0000000..22466e7 --- /dev/null +++ b/internal/repositories/customer_identity_scope.go @@ -0,0 +1,51 @@ +package repositories + +import ( + "errors" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "gorm.io/gorm" +) + +// ErrAmbiguousCustomerIdentity requires deliberate operator reconciliation. Never +// choose the first row: the historical unique index also includes customer_id. +var ErrAmbiguousCustomerIdentity = errors.New("ambiguous customer identity") + +// GetUniqueBy includes disabled rows when detecting conflicts. A disabled mapping +// must not let a caller create a replacement customer and reacquire the identity. +func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, source enums.ExternalSource, externalID string) (*models.CustomerIdentity, error) { + if strings.TrimSpace(string(source)) == "" || strings.TrimSpace(externalID) == "" { + return nil, errors.New("invalid customer identity") + } + var identities []models.CustomerIdentity + query := db.Where("identity_scope = ? AND external_source = ? AND external_id = ?", scope, source, strings.TrimSpace(externalID)) + if key := ScopedCustomerIdentityKey(scope, source, externalID); key != nil { + // Digest comparison is independent of MySQL's external_id collation. The + // NULL branch also detects manually migrated/inconsistent scoped mappings. + query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID)) + } + err := query.Limit(2).Find(&identities).Error + if err != nil { return nil, err } + if len(identities) > 1 { return nil, ErrAmbiguousCustomerIdentity } + if len(identities) == 0 { return nil, nil } + if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) { return nil, ErrAmbiguousCustomerIdentity } + if identities[0].Status != enums.StatusOk { return nil, errors.New("inactive customer identity") } + return &identities[0], nil +} + +// ScopedCustomerIdentityKey gives new scoped registrations a database-enforced +// claim without imposing a new unique constraint on dirty legacy data. Both +// SQLite and MySQL permit multiple NULL values in a unique index. Encoding an +// array avoids delimiter collisions and hashing avoids collation ambiguity. +func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string { + if scope == "" { return nil } + encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)}) + digest := sha256.Sum256(encoded) + key := hex.EncodeToString(digest[:]) + return &key +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index f8cbbd3..2c379f0 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -50,21 +52,27 @@ func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal { return nil } -func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal { - principal := &dto.AuthPrincipal{ - UserID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - Avatar: user.Avatar, - UserType: user.UserType, - Status: user.Status, - Roles: roles, - Permissions: permissions, - } +func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal { + principal := buildAuthPrincipal(user, session, roles, permissions) ctx.Set(authPrincipalContextKey, principal) return principal } +func buildAuthPrincipal(user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal { + return &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Avatar: user.Avatar, + UserType: user.UserType, + Status: user.Status, + Roles: slices.Clone(roles), + Permissions: slices.Clone(permissions), + LoginSessionID: session.ID, + ExpiresAt: session.ExpiredAt, + } +} + func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) { if principal = s.GetAuthPrincipal(ctx); principal == nil { if principal, err = s.Authenticate(ctx); err != nil { @@ -96,7 +104,7 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, } user := UserService.GetByUsername(username) - if user == nil || user.Status != enums.StatusOk { + if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk { _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found") return nil, errorsx.InvalidAccountI18n("error.e0260") } @@ -165,7 +173,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error) } user := UserService.Get(session.UserID) - if user == nil || user.Status != enums.StatusOk { + if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk { return nil, errorsx.UnauthorizedI18n("error.e0256") } @@ -173,7 +181,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error) if err != nil { return nil, err } - principal := s.setAuthPrincipal(ctx, user, roles, permissions) + principal := s.setAuthPrincipal(ctx, user, session, roles, permissions) now := time.Now() _ = LoginSessionService.Updates(session.ID, map[string]any{ @@ -184,6 +192,39 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error) return principal, nil } +// RevalidatePrincipal refreshes a long-lived connection's authorization without +// keeping the bearer token. The original snapshot is never mutated and its +// absolute deadline cannot be extended by revalidation. +func (s *authService) RevalidatePrincipal(principal *dto.AuthPrincipal) (*dto.AuthPrincipal, error) { + return s.revalidatePrincipal(sqls.DB(), principal, time.Now()) +} + +func (s *authService) revalidatePrincipal(db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) (*dto.AuthPrincipal, error) { + if principal == nil || principal.UserID <= 0 || principal.LoginSessionID <= 0 || !now.Before(principal.ExpiresAt) { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + + session, err := repositories.LoginSessionRepository.GetAuthState(db, principal.LoginSessionID, principal.UserID) + if err != nil || session == nil || session.RevokedAt != nil || !now.Before(session.ExpiredAt) { + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + user, err := repositories.UserRepository.GetAuthState(db, principal.UserID) + if err != nil || user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + roles, permissions, err := s.loadUserAuthScope(db, user.ID) + if err != nil { + // Database failures must close the connection, not retain its old scope or + // expose persistence details through a transport error. + return nil, errorsx.UnauthorizedI18n("error.auth.expired") + } + fresh := buildAuthPrincipal(user, session, roles, permissions) + if principal.ExpiresAt.Before(fresh.ExpiresAt) { + fresh.ExpiresAt = principal.ExpiresAt + } + return fresh, nil +} + func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool { principal := s.GetAuthPrincipal(ctx) if principal == nil { @@ -341,7 +382,7 @@ func (s *authService) validateSessionToken(token string) (*models.LoginSession, if session.RevokedAt != nil { return nil, errorsx.InvalidTokenI18n("error.e0267") } - if time.Now().After(session.ExpiredAt) { + if !time.Now().Before(session.ExpiredAt) { return nil, errorsx.InvalidTokenI18n("error.e0268") } return session, nil @@ -372,49 +413,16 @@ func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, er } func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) { - roles := make([]models.Role, 0) - if err := tx. - Table("t_role AS r"). - Select("r.*"). - Joins("JOIN t_user_role AS ur ON ur.role_id = r.id"). - Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk). - Order("r.sort_no ASC, r.id ASC"). - Scan(&roles).Error; err != nil { - return nil, err - } - - return roles, nil + return repositories.RoleRepository.FindEnabledByUser(tx, userID) } func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) { - permissionRows := make([]struct { - Code string - }, 0) - db := tx.Table("t_permission AS p"). - Select("DISTINCT p.code"). - Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id"). - Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id"). - Where("ur.user_id = ?", userID). - Where("p.status = ?", enums.StatusOk) - if err := db.Scan(&permissionRows).Error; err != nil { + permissionCodes, err := repositories.PermissionRepository.FindEnabledCodesByUserRoles(tx, userID) + if err != nil { return nil, err } - - permissionCodes := make([]string, 0, len(permissionRows)) - for _, permission := range permissionRows { - permissionCodes = append(permissionCodes, permission.Code) - } - - overrideRows := make([]struct { - Code string - Effect int - }, 0) - if err := tx. - Table("t_user_permission AS up"). - Select("p.code, up.effect"). - Joins("JOIN t_permission AS p ON p.id = up.permission_id"). - Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()). - Scan(&overrideRows).Error; err != nil { + overrideRows, err := repositories.UserPermissionRepository.FindActiveEnabledOverrides(tx, userID, time.Now()) + if err != nil { return nil, err } diff --git a/internal/services/auth_session_security_test.go b/internal/services/auth_session_security_test.go new file mode 100644 index 0000000..b0f620f --- /dev/null +++ b/internal/services/auth_session_security_test.go @@ -0,0 +1,246 @@ +package services + +import ( + "encoding/json" + "net/http/httptest" + "path/filepath" + "slices" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +type authSessionFixture struct { + db *gorm.DB + now time.Time + user models.User + session models.LoginSession + role models.Role + permission models.Permission + principal *dto.AuthPrincipal +} + +func newAuthSessionFixture(t *testing.T) *authSessionFixture { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "auth.db")), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open isolated auth database: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&models.User{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}); err != nil { + t.Fatalf("migrate isolated auth database: %v", err) + } + f := &authSessionFixture{db: db, now: time.Now().UTC().Truncate(time.Microsecond)} + f.user = models.User{Username: "staff", UserType: enums.UserTypeEmployee, Status: enums.StatusOk} + f.role = models.Role{Code: "support", Status: enums.StatusOk} + f.permission = models.Permission{Code: "conversation.read", Status: enums.StatusOk} + for _, item := range []any{&f.user, &f.role, &f.permission} { + if err := db.Create(item).Error; err != nil { + t.Fatalf("seed auth fixture: %v", err) + } + } + f.session = models.LoginSession{UserID: f.user.ID, Token: "ak_test_only_session", ExpiredAt: f.now.Add(time.Hour)} + for _, item := range []any{ + &f.session, + &models.UserRole{UserID: f.user.ID, RoleID: f.role.ID}, + &models.RolePermission{RoleID: f.role.ID, PermissionID: f.permission.ID}, + } { + if err := db.Create(item).Error; err != nil { + t.Fatalf("seed auth fixture: %v", err) + } + } + f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, []string{f.permission.Code}) + return f +} + +func TestAuthScopeExcludesDisabledRolesAndPermissionOverrides(t *testing.T) { + f := newAuthSessionFixture(t) + disabledRole := models.Role{Code: "disabled", Status: enums.StatusDisabled} + if err := f.db.Create(&disabledRole).Error; err != nil { + t.Fatal(err) + } + if err := f.db.Create(&models.UserRole{UserID: f.user.ID, RoleID: disabledRole.ID}).Error; err != nil { + t.Fatal(err) + } + permissions := []models.Permission{ + {Code: "disabled-role-only", Status: enums.StatusOk}, + {Code: "disabled-permission", Status: enums.StatusDisabled}, + {Code: "direct-allow", Status: enums.StatusOk}, + {Code: "expired-allow", Status: enums.StatusOk}, + } + if err := f.db.Create(&permissions).Error; err != nil { + t.Fatal(err) + } + for _, item := range []any{ + &models.RolePermission{RoleID: disabledRole.ID, PermissionID: permissions[0].ID}, + &models.RolePermission{RoleID: f.role.ID, PermissionID: permissions[1].ID}, + &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[1].ID, Effect: 1}, + &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[2].ID, Effect: 1}, + &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[3].ID, Effect: 1, ExpiredAt: &f.now}, + &models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}, + } { + if err := f.db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + roles, codes, err := newAuthService().loadUserAuthScope(f.db, f.user.ID) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(roles, []string{"support"}) || !slices.Equal(codes, []string{"direct-allow"}) { + t.Fatalf("unexpected enabled authorization scope: roles=%v permissions=%v", roles, codes) + } +} + +func TestRevalidatePrincipalRejectsRevokedExpiredOrUnboundIdentity(t *testing.T) { + cases := []struct { + name string + change func(*authSessionFixture) error + }{ + {"nil principal", func(f *authSessionFixture) error { f.principal = nil; return nil }}, + {"missing session binding", func(f *authSessionFixture) error { f.principal.LoginSessionID = 0; return nil }}, + {"missing user binding", func(f *authSessionFixture) error { f.principal.UserID = 0; return nil }}, + {"missing expiry", func(f *authSessionFixture) error { f.principal.ExpiresAt = time.Time{}; return nil }}, + {"original deadline reached", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now; return nil }}, + {"original deadline passed", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now.Add(-time.Second); return nil }}, + {"revoked session", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", f.now).Error }}, + {"session deadline reached", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now).Error }}, + {"session deadline passed", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error }}, + {"deleted session", func(f *authSessionFixture) error { return f.db.Delete(&f.session).Error }}, + {"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID + 1).Error }}, + {"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }}, + {"soft-deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", f.now).Error }}, + {"deleted user", func(f *authSessionFixture) error { return f.db.Delete(&f.user).Error }}, + {"failed session query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.LoginSession{}) }}, + {"failed scope query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.Permission{}) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newAuthSessionFixture(t) + if err := tc.change(f); err != nil { + t.Fatal(err) + } + fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) + if fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) { + t.Fatalf("expected fail-closed unauthorized result, got principal=%v err=%v", fresh != nil, err) + } + }) + } +} + +func TestRevalidatePrincipalRefreshesScopeWithoutMutatingSnapshot(t *testing.T) { + cases := []struct { + name string + change func(*authSessionFixture) error + wantRoles []string + }{ + {"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }, nil}, + {"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }, nil}, + {"removed role permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }, []string{"support"}}, + {"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }, []string{"support"}}, + {"direct deny", func(f *authSessionFixture) error { return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error }, []string{"support"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newAuthSessionFixture(t) + if err := tc.change(f); err != nil { + t.Fatal(err) + } + fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) + if err != nil { + t.Fatal(err) + } + if fresh == f.principal || len(fresh.Permissions) != 0 || !slices.Equal(fresh.Roles, tc.wantRoles) { + t.Fatalf("expected a fresh reduced scope: roles=%v permissions=%v", fresh.Roles, fresh.Permissions) + } + if !slices.Equal(f.principal.Roles, []string{"support"}) || !slices.Equal(f.principal.Permissions, []string{"conversation.read"}) { + t.Fatal("revalidation mutated the original snapshot") + } + }) + } +} + +func TestRevalidatePrincipalCannotExtendAbsoluteExpiry(t *testing.T) { + for _, delta := range []time.Duration{-time.Minute, time.Minute} { + t.Run(delta.String(), func(t *testing.T) { + f := newAuthSessionFixture(t) + storedExpiry := f.session.ExpiredAt.Add(delta) + if err := f.db.Model(&f.session).Update("expired_at", storedExpiry).Error; err != nil { + t.Fatal(err) + } + fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) + if err != nil { + t.Fatal(err) + } + want := f.principal.ExpiresAt + if storedExpiry.Before(want) { + want = storedExpiry + } + if !fresh.ExpiresAt.Equal(want) { + t.Fatal("revalidation did not preserve the earliest absolute deadline") + } + fresh.Roles[0] = "changed" + fresh.Permissions[0] = "changed" + if f.principal.Roles[0] != "support" || f.principal.Permissions[0] != "conversation.read" { + t.Fatal("fresh authorization slices alias the original snapshot") + } + }) + } +} + +func TestAuthenticateBindsPrincipalToSessionWithoutExposingMetadata(t *testing.T) { + f := newAuthSessionFixture(t) + previousDB := sqls.DB() + sqls.SetDB(f.db) + t.Cleanup(func() { sqls.SetDB(previousDB) }) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.Header.Set("Authorization", "Bearer " + f.session.Token) + principal, err := newAuthService().Authenticate(ctx) + if err != nil { + t.Fatal(err) + } + if principal.LoginSessionID != f.session.ID || !principal.ExpiresAt.Equal(f.session.ExpiredAt) { + t.Fatal("authenticated principal is not bound to its original session") + } + if _, err := newAuthService().RevalidatePrincipal(principal); err != nil { + t.Fatalf("valid session failed public revalidation API: %v", err) + } + encoded, err := json.Marshal(principal) + if err != nil { + t.Fatal(err) + } + var publicFields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &publicFields); err != nil { + t.Fatal(err) + } + for _, name := range []string{"LoginSessionID", "ExpiresAt", "Token"} { + if _, ok := publicFields[name]; ok { + t.Fatalf("internal auth metadata leaked into JSON: %s", name) + } + } + if err := newAuthService().Logout("Bearer " + f.session.Token); err != nil { + t.Fatal(err) + } + if fresh, err := newAuthService().RevalidatePrincipal(principal); fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) { + t.Fatalf("logout left the authenticated snapshot valid: principal=%v err=%v", fresh != nil, err) + } +} diff --git a/internal/services/channel_policy_concurrency_test.go b/internal/services/channel_policy_concurrency_test.go new file mode 100644 index 0000000..6f1a950 --- /dev/null +++ b/internal/services/channel_policy_concurrency_test.go @@ -0,0 +1,78 @@ +package services + +import ( + "sync/atomic" + "encoding/json" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestChannelConfigCompareAndSwapRejectsStalePolicyAndSecretWrites(t *testing.T) { + for _, action:=range []string{"editor", "secret-reset"} { + t.Run(action,func(t *testing.T) { + previousDB:=sqls.DB() + db:=setupChannelServiceTestDB(t) + t.Cleanup(func(){sqls.SetDB(previousDB)}) + agent:=createChannelServiceTestAgent(t,db,1001) + channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Policy channel",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":false,"userTokenSecret":"test-old-secret"}`},channelServiceTestOperator()) + if err!=nil {t.Fatal(err)} + var injected atomic.Bool + callbackName:="test:concurrent_channel_policy" + // Commit another writer after the stale service read, just before its SQL. + // Preserve updated_at deliberately: bytewise JSON CAS must also catch this. + if err:=db.Callback().Update().Before("gorm:update").Register(callbackName,func(tx *gorm.DB){ + if tx.Statement.Schema==nil || tx.Statement.Schema.Table!="t_channel" || !injected.CompareAndSwap(false,true){return} + cfg,parseErr:=ChannelService.ParseWebChannelConfig(channel.ConfigJSON) + if parseErr!=nil {tx.AddError(parseErr);return} + cfg.RequireSignedIdentity=true + cfg.UserTokenSecret="test-new-secret" + encoded,encodeErr:=json.Marshal(cfg) + if encodeErr!=nil {tx.AddError(encodeErr);return} + if writeErr:=db.Session(&gorm.Session{NewDB:true}).Model(&models.Channel{}).Where("id = ?",channel.ID).Updates(map[string]any{"config_json":string(encoded),"updated_at":channel.UpdatedAt}).Error;writeErr!=nil{tx.AddError(writeErr)} + });err!=nil{t.Fatal(err)} + t.Cleanup(func(){_ = db.Callback().Update().Remove(callbackName)}) + switch action { + case "editor": err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Old editor title",Status:int(enums.StatusOk),ConfigJSON:`{"title":"old editor"}`}},channelServiceTestOperator()) + case "secret-reset": var secret string;secret,err=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator());if secret!=""{t.Fatal("failed reset returned an uncommitted secret")} + } + if !injected.Load() || err==nil {t.Fatal("stale writer was not rejected")} + current:=ChannelService.Get(channel.ID) + cfg,parseErr:=ChannelService.ParseWebChannelConfig(current.ConfigJSON) + if parseErr!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!="test-new-secret" {t.Fatal("stale writer replaced the concurrent policy/secret")} + }) + } +} + +func TestChannelConfigCompareAndSwapUsesExactBytes(t *testing.T) { + db,channel:=setupIdentitySecurityDB(t) + channel.ConfigJSON=`{"userTokenSecret":"CaseSensitive"}` + if err:=db.Model(channel).Updates(map[string]any{"config_json":channel.ConfigJSON,"updated_at":time.Now()}).Error;err!=nil{t.Fatal(err)} + expected:=*channel + if err:=db.Model(channel).UpdateColumn("config_json",`{"userTokenSecret":"casesensitive"}`).Error;err!=nil{t.Fatal(err)} + updated,err:=repositories.ChannelRepository.UpdatesConfigIfUnchanged(db,&expected,map[string]any{"config_json":`{"requireSignedIdentity":false}`}) + if err!=nil{t.Fatal(err)} + if updated{t.Fatal("case-sensitive secret change was not treated as a conflict")} +} + +func TestChannelConfigCompareAndSwapAllowsCurrentEditorAndReset(t *testing.T) { + previousDB:=sqls.DB() + db:=setupChannelServiceTestDB(t) + t.Cleanup(func(){sqls.SetDB(previousDB)}) + agent:=createChannelServiceTestAgent(t,db,1001) + channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":true}`},channelServiceTestOperator()) + if err!=nil{t.Fatal(err)} + err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy edited",Status:int(enums.StatusOk),ConfigJSON:`{"title":"Updated title"}`}},channelServiceTestOperator()) + if err!=nil{t.Fatal(err)} + secret,err:=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator()) + if err!=nil || secret==""{t.Fatal("current secret reset failed")} + cfg,err:=ChannelService.ParseWebChannelConfig(ChannelService.Get(channel.ID).ConfigJSON) + if err!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!=secret{t.Fatal("current update/reset lost policy or returned unsaved secret")} +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index f5248b9..4464adb 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -98,6 +100,13 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato if current == nil || current.Status == enums.StatusDeleted { return errorsx.InvalidParamI18n("error.e0208") } + if current.ChannelType == enums.ChannelTypeWeb && strings.TrimSpace(req.ChannelType) == enums.ChannelTypeWeb { + // Older dashboard clients do not expose this policy yet. Omitting the field + // during an unrelated edit must not silently turn authenticated-only off. + merged, err := s.preserveWebIdentityPolicy(req.ConfigJSON, current.ConfigJSON) + if err != nil { return err } + req.ConfigJSON = merged + } item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest) if err != nil { return err @@ -118,7 +127,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent { columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent } - return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns) + return s.updateConfigIfUnchanged(current, columns) } // RollbackChannelAIAgentRollout restores the last channel-level rollout value @@ -196,6 +205,11 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi if err := json.Unmarshal([]byte(raw), cfg); err != nil { return nil, err } + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &fields); err != nil { return nil, err } + if policy, ok := fields["requireSignedIdentity"]; ok && string(policy) != "true" && string(policy) != "false" { + return nil, errorsx.InvalidParamI18n("error.e0060") + } } cfg.Title = strings.TrimSpace(cfg.Title) if cfg.Title == "" { @@ -459,7 +473,7 @@ func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.Aut } configJSON = string(raw) } - if err := repositories.ChannelRepository.Updates(sqls.DB(), channelID, map[string]any{ + if err := s.updateConfigIfUnchanged(channel, map[string]any{ "config_json": configJSON, "update_user_id": operator.UserID, "update_user_name": operator.Username, @@ -683,3 +697,36 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe Remark: strings.TrimSpace(req.Remark), }, nil } + +// RequiresSignedIdentity enforces the channel policy on the server, not only in +// the embedding SDK. Invalid web configuration fails closed. +func (s *channelService) RequiresSignedIdentity(channel *models.Channel) bool { + if channel == nil { return true } + if channel.ChannelType != enums.ChannelTypeWeb { return false } + cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON) + return err != nil || cfg.RequireSignedIdentity +} + +func (s *channelService) preserveWebIdentityPolicy(raw, previousRaw string) (string, error) { + fields := map[string]json.RawMessage{} + if strings.TrimSpace(raw) != "" { + if err := json.Unmarshal([]byte(raw), &fields); err != nil { return "", errorsx.InvalidParamI18n("error.e0060") } + } + if _, present := fields["requireSignedIdentity"]; present { return raw, nil } + previous, err := s.ParseWebChannelConfig(previousRaw) + if err != nil { return "", errorsx.InvalidParamI18n("error.e0060") } + if !previous.RequireSignedIdentity { return raw, nil } + if fields == nil { fields = map[string]json.RawMessage{} } + fields["requireSignedIdentity"] = json.RawMessage("true") + encoded, err := json.Marshal(fields) + return string(encoded), err +} + +// Reject rather than retry a stale complete config document: retrying could turn +// an old explicit field into authority to overwrite the new policy/secret. +func (s *channelService) updateConfigIfUnchanged(expected *models.Channel, columns map[string]any) error { + updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(sqls.DB(), expected, columns) + if err != nil { return errorsx.BusinessErrorI18n(0, "error.channel.configUpdateFailed") } + if !updated { return errorsx.InvalidParamI18n("error.channel.configChanged") } + return nil +} diff --git a/internal/services/conversation_dispatch_service.go b/internal/services/conversation_dispatch_service.go index 4ff7e35..9c965f8 100644 --- a/internal/services/conversation_dispatch_service.go +++ b/internal/services/conversation_dispatch_service.go @@ -1,3 +1,4 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package services import ( @@ -151,10 +152,12 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i if limit <= 0 { limit = pendingDispatchBatchLimit } - conversations := ConversationService.Find(sqls.NewCnd(). - Eq("status", enums.IMConversationStatusPending). - Eq("current_assignee_id", 0). - Desc("id")) + // Preserve the existing newest-first batch/periodic retry policy, but + // bound the SQL read and distinguish failure from an empty queue. + conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit) + if err != nil { + return 0, err + } if len(conversations) == 0 { return 0, nil } diff --git a/internal/services/conversation_human_dispatch_realtime_test.go b/internal/services/conversation_human_dispatch_realtime_test.go index ad01258..3d769e5 100644 --- a/internal/services/conversation_human_dispatch_realtime_test.go +++ b/internal/services/conversation_human_dispatch_realtime_test.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -112,11 +114,13 @@ func findHumanDispatchRealtimeEvent(t *testing.T, session *ClientSession, eventT func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession { t.Helper() + principal := seedRealtimeStaffPrincipal(t, sqls.DB()) session := &ClientSession{ - ID: "test-session", - Role: realtimeRoleAdmin, - Topics: map[string]struct{}{}, - Send: make(chan []byte, 32), + ID: "test-session", + Role: realtimeRoleAdmin, + Principal: principal, + Topics: map[string]struct{}{}, + Send: make(chan []byte, 32), } WsService.manager.Register(session, topics) return session @@ -124,6 +128,7 @@ func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *Client func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB { t.Helper() + previousDB, previousWs := sqls.DB(), WsService dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ @@ -161,6 +166,10 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) + t.Cleanup(func() { + sqls.SetDB(previousDB) + WsService = previousWs + }) return db } diff --git a/internal/services/conversation_service.go b/internal/services/conversation_service.go index c0a28b8..781550e 100644 --- a/internal/services/conversation_service.go +++ b/internal/services/conversation_service.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -317,17 +319,14 @@ func (s *conversationService) CreateNew(externalUser openidentity.ExternalUser, // activity first, so the widget can offer a request list. A visitor who has not // talked to us yet has no identity row and therefore an empty list, not an error. // -// Scoping is by customer, not by channel: a CustomerIdentity is keyed by -// (external_source, external_id) globally, so one signed visitor reaching us -// through two channels is one person with one request history. For a guest the -// external id is issued per channel, so their identities cannot collide across -// channels anyway. Narrowing this to the current channel would silently hide the -// visitor's earlier requests. +// Public identities are scoped to a server-verified issuer and channel. Legacy +// internal integrations may still use their explicitly unscoped identity mapping. func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) { if cnd == nil { cnd = sqls.NewCnd() } - identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID)) + identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID)) + if err != nil { return nil, nil, errorsx.UnauthorizedI18n("error.e0161") } if identity == nil { paging := &sqls.Paging{} if cnd.Paging != nil { @@ -336,6 +335,7 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit return []models.Conversation{}, paging, nil } cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id") + if externalUser.ChannelID > 0 { cnd.Eq("channel_id", externalUser.ChannelID) } list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd) if list == nil { list = []models.Conversation{} @@ -832,8 +832,9 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 { return false } - identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID) - if identity == nil { + if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID { return false } + identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID) + if err != nil || identity == nil { return false } return identity.CustomerID == conversation.CustomerID diff --git a/internal/services/customer_identity_security_test.go b/internal/services/customer_identity_security_test.go new file mode 100644 index 0000000..4bd64c2 --- /dev/null +++ b/internal/services/customer_identity_security_test.go @@ -0,0 +1,272 @@ +package services + +import ( + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/openidentity" + "agent-desk/internal/repositories" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +func setupIdentitySecurityDB(t *testing.T) (*gorm.DB, *models.Channel) { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "identity.db")), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { t.Fatal(err) } + if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil { t.Fatal(err) } + previousDB, previousConfig := sqls.DB(), config.GetCurrent() + sqls.SetDB(db) + config.SetCurrent(&config.Config{CustomerSession: config.CustomerSessionConfig{Secret: "test-only-customer-session-key", TTLMinutes: 120}}) + t.Cleanup(func() { sqls.SetDB(previousDB); config.SetCurrent(previousConfig); sqlDB, _ := db.DB(); _ = sqlDB.Close() }) + channel := &models.Channel{ChannelID: "channel-a", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"} + if err := db.Create(channel).Error; err != nil { t.Fatal(err) } + return db, channel +} + +func scopedTestExternal(channel *models.Channel, issuer, id string) openidentity.ExternalUser { + source := enums.ExternalSourceUser + if issuer == "guest" { source = enums.ExternalSourceGuest } + return openidentity.ExternalUser{ExternalSource: source, ExternalID: id, IdentityScope: openidentity.IdentityScope(channel.ChannelID, issuer), ChannelID: channel.ID} +} + +func verifySecurityCustomerSession(t *testing.T, channel *models.Channel, token string) *CustomerSessionVerifyResult { + t.Helper() + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("GET", "/api/ws/open", nil) + ctx.Request.Header.Set("Authorization", "Bearer " + token) + result, err := CustomerSessionService.VerifyRequest(ctx, channel) + if err != nil { t.Fatalf("verify customer credential: %v", err) } + return result +} + +func TestCustomerIdentitySeparatesTrustIssuerAndChannel(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + otherChannel := &models.Channel{ChannelID: "channel-b", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"} + if err := db.Create(otherChannel).Error; err != nil { t.Fatal(err) } + customerIDs := map[int64]bool{} + for _, input := range []struct{ channel *models.Channel; issuer string }{{channel,"hmac"},{channel,"jwt"},{channel,"guest"},{otherChannel,"hmac"}} { + exchanged, err := CustomerSessionService.Exchange(input.channel, scopedTestExternal(input.channel, input.issuer, "same-id")) + if err != nil { t.Fatal(err) } + if customerIDs[exchanged.Customer.ID] { t.Fatal("independent identity scopes were merged") } + customerIDs[exchanged.Customer.ID] = true + result := verifySecurityCustomerSession(t, input.channel, exchanged.CustomerSessionToken) + if !CustomerSessionService.VerifyConnection(result) { t.Fatal("fresh scoped connection rejected") } + wrongChannel := &models.Conversation{CustomerID: result.CustomerID, ChannelID: otherChannel.ID} + if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) { t.Fatal("customer acquired a different channel's conversation") } + } +} + +func TestGuestBootstrapCannotReclaimKnownAnonymousIdentity(t *testing.T) { + _, channel := setupIdentitySecurityDB(t) + first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest","caller-chosen-id")) + if err != nil { t.Fatal(err) } + stolenID := strings.TrimPrefix(first.IdentityKey,"guest:") + if len(stolenID) != 64 || stolenID == "caller-chosen-id" { t.Fatal("guest identity was not server-generated") } + second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest",stolenID)) + if err != nil { t.Fatal(err) } + if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey { t.Fatal("public guest bootstrap reclaimed another guest identity") } + original := verifySecurityCustomerSession(t,channel,first.CustomerSessionToken) + if original.CustomerID != first.Customer.ID { t.Fatal("signed guest credential did not retain its owner") } +} + +func TestCustomerIdentityDuplicatesFailClosedWithoutCreatingAnotherCustomer(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + external := scopedTestExternal(channel,"hmac","duplicate") + for i:=0; i<2; i++ { + customer := &models.Customer{Status: enums.StatusOk} + if err := db.Create(customer).Error; err != nil { t.Fatal(err) } + identity := &models.CustomerIdentity{CustomerID: customer.ID, IdentityScope: external.IdentityScope, ExternalSource: external.ExternalSource, ExternalID: external.ExternalID, Status: enums.StatusOk} + if err := db.Create(identity).Error; err != nil { t.Fatal(err) } + } + if _, err := CustomerSessionService.Exchange(channel,external); err == nil { t.Fatal("ambiguous identity was accepted") } + var count int64 + if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil { t.Fatal(err) } + if count != 2 { t.Fatalf("ambiguous mapping created another customer: %d",count) } + identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db,external.IdentityScope,external.ExternalSource,external.ExternalID) + if err == nil || identity != nil { t.Fatal("duplicate lookup did not fail closed") } +} + +func TestCustomerSessionsRejectLegacyScopeAndLivePolicyChanges(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest")) + if err != nil { t.Fatal(err) } + current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken) + claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken) + if err != nil { t.Fatal(err) } + claims.IdentityScope = "" + legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret)) + if err != nil { t.Fatal(err) } + if _, err := CustomerSessionService.verifyToken(legacy); err == nil { t.Fatal("unscoped legacy credential accepted") } + if err := db.Model(channel).Update("config_json",`{"requireSignedIdentity":true}`).Error; err != nil { t.Fatal(err) } + if CustomerSessionService.VerifyConnection(current) { t.Fatal("existing guest retained access after signed-only policy change") } + channel.ConfigJSON=`{"requireSignedIdentity":true}` + if _, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest")); err == nil { t.Fatal("signed-only channel accepted guest exchange") } +} + +func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *testing.T) { + for _, scenario := range []string{"expiry","disabled-customer","disabled-channel","disabled-identity","rebound-identity","duplicate-identity"} { + t.Run(scenario,func(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","signed")) + if err != nil { t.Fatal(err) } + current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken) + switch scenario { + case "expiry": current.ExpiresAt=time.Now() + case "disabled-customer": err=db.Model(&models.Customer{}).Where("id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error + case "disabled-channel": err=db.Model(channel).Update("status",enums.StatusDisabled).Error + case "disabled-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error + case "rebound-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("customer_id",current.CustomerID+100).Error + case "duplicate-identity": err=db.Create(&models.CustomerIdentity{CustomerID:current.CustomerID+100,ExternalSource:current.ExternalUser.ExternalSource,ExternalID:current.ExternalUser.ExternalID,IdentityScope:current.ExternalUser.IdentityScope,Status:enums.StatusOk}).Error + } + if err != nil { t.Fatal(err) } + if CustomerSessionService.VerifyConnection(current) { t.Fatal("invalidated customer connection retained authority") } + }) + } +} + +func TestCustomerRealtimeTopicsAndDeliveryRejectCrossScopeCollisions(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + ws := newWsService() + signed, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","known-id")) + if err != nil { t.Fatal(err) } + guest, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","known-id")) + if err != nil { t.Fatal(err) } + signedAuth:=verifySecurityCustomerSession(t,channel,signed.CustomerSessionToken) + guestAuth:=verifySecurityCustomerSession(t,channel,guest.CustomerSessionToken) + conversation:= &models.Conversation{CustomerID:signed.Customer.ID,ChannelID:channel.ID} + if err:=db.Create(conversation).Error;err!=nil { t.Fatal(err) } + owner:=&ClientSession{ID:"owner",Role:realtimeRoleUser,External:signedAuth.ExternalUser,CustomerSession:signedAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)} + attacker:=&ClientSession{ID:"guest",Role:realtimeRoleUser,External:guestAuth.ExternalUser,CustomerSession:guestAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)} + if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(guest.Customer.ID,channel.ID) { t.Fatal("trust namespaces collided") } + if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(signed.Customer.ID,channel.ID+1) { t.Fatal("channel namespaces collided") } + ws.manager.Register(owner,ws.defaultTopics(owner)) + // Defense in depth: even an accidentally stale/wrong topic registration may + // not grant the guest authorization to the signed customer's conversation. + ws.manager.Register(attacker,ws.routeConversationTopics(conversation)) + ws.PublishAgentActivity(conversation,"step","","running") + select { case <-owner.Send: default: t.Fatal("owner did not receive its event") } + select { case <-attacker.Send: t.Fatal("guest received another customer's event"); default: } + if ws.canSubscribeConversation(attacker,conversation.ID) { t.Fatal("explicit foreign conversation subscription allowed") } +} + +func TestScopedIdentityUniqueClaimPreservesLegacyRows(t *testing.T) { + db, channel := setupIdentitySecurityDB(t) + source := enums.ExternalSourceUser + // Dirty legacy mappings remain migratable because their unique claim is NULL. + for _, customerID := range []int64{100,101} { + if err := db.Create(&models.CustomerIdentity{CustomerID:customerID,ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal(err) } + } + scope:=openidentity.IdentityScope(channel.ChannelID,"hmac") + first:=&models.CustomerIdentity{CustomerID:102,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk} + if err:=db.Create(first).Error;err!=nil { t.Fatal(err) } + competing:=&models.CustomerIdentity{CustomerID:103,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk} + if err:=db.Create(competing).Error;err==nil { t.Fatal("database allowed a competing scoped identity claim") } + otherScope:=openidentity.IdentityScope(channel.ChannelID,"jwt") + if err:=db.Create(&models.CustomerIdentity{CustomerID:104,IdentityScope:otherScope,ScopedKey:repositories.ScopedCustomerIdentityKey(otherScope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal("different issuer's claim was conflated") } + found,err:=repositories.CustomerIdentityRepository.GetUniqueBy(db,scope,source,"same") + if err!=nil || found==nil || found.CustomerID!=102 { t.Fatal("legacy mappings interfered with scoped identity") } +} + +func TestWebIdentityPolicySurvivesOlderClientEdits(t *testing.T) { + svc:=newChannelService() + raw,err:=svc.preserveWebIdentityPolicy(`{"title":"New title"}`,`{"requireSignedIdentity":true}`) + if err!=nil { t.Fatal(err) } + cfg,err:=svc.ParseWebChannelConfig(raw) + if err!=nil || !cfg.RequireSignedIdentity { t.Fatal("older client edit silently disabled signed-only policy") } + raw,err=svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`,`{"requireSignedIdentity":true}`) + if err!=nil { t.Fatal(err) } + cfg,err=svc.ParseWebChannelConfig(raw) + if err!=nil || cfg.RequireSignedIdentity { t.Fatal("explicit policy change was not honored") } + for _,raw:=range []string{`{"requireSignedIdentity":null}`,`{"requireSignedIdentity":"false"}`,`{"requireSignedIdentity":0}`} { + if _,err:=svc.ParseWebChannelConfig(raw);err==nil { t.Fatal("non-boolean authentication policy accepted") } + } +} + +func TestCustomerSessionNearExpiryRefreshPreservesIdentity(t *testing.T) { + db,channel:=setupIdentitySecurityDB(t) + exchanged,err:=CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","local-context")) + if err!=nil{t.Fatal(err)} + claims,err:=CustomerSessionService.verifyToken(exchanged.CustomerSessionToken) + if err!=nil{t.Fatal(err)} + originalKey,originalScope,originalCustomer:=claims.IdentityKey,claims.IdentityScope,claims.CustomerID + claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(20*time.Second)) + nearExpiry,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret)) + if err!=nil{t.Fatal(err)} + refreshed:=verifySecurityCustomerSession(t,channel,nearExpiry) + if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token==nearExpiry {t.Fatal("near-expiry credential was not refreshed")} + refreshedClaims,err:=CustomerSessionService.verifyToken(refreshed.Token) + if err!=nil{t.Fatal(err)} + if refreshedClaims.IdentityKey!=originalKey || refreshedClaims.IdentityScope!=originalScope || refreshedClaims.CustomerID!=originalCustomer {t.Fatal("refresh changed anonymous identity")} + var customers int64 + if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)} + if customers!=1{t.Fatal("refresh allocated another customer")} + claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(-time.Second)) + expired,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret)) + if err!=nil{t.Fatal(err)} + ctx,_:=gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request=httptest.NewRequest("POST","/api/customer/session_refresh",nil) + ctx.Request.Header.Set("Authorization","Bearer "+expired) + if _,err:=CustomerSessionService.VerifyRequest(ctx,channel);err==nil{t.Fatal("expired anonymous credential was silently renewed")} +} + +func TestConcurrentCustomerSessionExchangesKeepOneScopedOwner(t *testing.T) { + db,channel:=setupIdentitySecurityDB(t) + if err:=db.Exec("PRAGMA journal_mode=WAL").Error;err!=nil{t.Fatal(err)} + pool,err:=db.DB();if err!=nil{t.Fatal(err)} + pool.SetMaxOpenConns(4) + external:=scopedTestExternal(channel,"hmac","concurrent-id") + var queries atomic.Int32 + arrived:=make(chan struct{},2) + release:=make(chan struct{}) + var releaseOnce sync.Once + releaseBoth:=func(){releaseOnce.Do(func(){close(release)})} + var exchanges sync.WaitGroup + defer func(){releaseBoth();exchanges.Wait()}() + callbackName:="test:concurrent_scoped_lookup" + if err:=db.Callback().Query().After("gorm:query").Register(callbackName,func(tx *gorm.DB){ + if tx.Statement.Table!="t_customer_identity" || tx.Error!=nil || tx.RowsAffected!=0{return} + if queries.Add(1)<=2{arrived<-struct{}{};<-release} + });err!=nil{t.Fatal(err)} + t.Cleanup(func(){_ = db.Callback().Query().Remove(callbackName)}) + type result struct{ value *response.CustomerSessionExchangeResponse; err error } + results:=make(chan result,2) + exchanges.Add(2) + for i:=0;i<2;i++{go func(){defer exchanges.Done();value,err:=CustomerSessionService.Exchange(channel,external);results<-result{value,err}}()} + for i:=0;i<2;i++{select{case <-arrived:case <-time.After(2*time.Second):releaseBoth();t.Fatal("concurrent transactions did not both reach initial identity lookup")}} + releaseBoth() + successes:=0 + var owner int64 + for i:=0;i<2;i++{ + select{ + case result:=<-results: + if result.err==nil{successes++;owner=result.value.Customer.ID} + case <-time.After(2*time.Second):t.Fatal("concurrent exchange did not finish") + } + } + if successes!=1{t.Fatalf("expected one transaction winner, got %d",successes)} + var customers,identities int64 + if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)} + if err:=db.Model(&models.CustomerIdentity{}).Count(&identities).Error;err!=nil{t.Fatal(err)} + if customers!=1 || identities!=1{t.Fatal("losing transaction left an orphan or competing customer mapping")} + retry,err:=CustomerSessionService.Exchange(channel,external) + if err!=nil || retry.Customer.ID!=owner{t.Fatal("retry did not resolve the unique winning owner")} +} diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go index 2514180..16ad4d8 100644 --- a/internal/services/customer_service.go +++ b/internal/services/customer_service.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -124,7 +126,11 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs return 0, errorsx.UnauthorizedI18n("error.e0149") } now := time.Now() - if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil { + identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(ctx.Tx, externalUser.IdentityScope, externalSource, externalID) + if err != nil { return 0, errorsx.UnauthorizedI18n("error.e0161") } + if identity != nil { + customer := repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID) + if customer == nil || customer.Status != enums.StatusOk { return 0, errorsx.UnauthorizedI18n("error.e0161") } updates := map[string]any{ "last_active_at": now, "updated_at": now, @@ -161,12 +167,16 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs } if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{ CustomerID: customer.ID, + IdentityScope: externalUser.IdentityScope, + ScopedKey: repositories.ScopedCustomerIdentityKey(externalUser.IdentityScope, externalSource, externalID), ExternalSource: externalSource, ExternalID: externalID, Status: enums.StatusOk, AuditFields: utils.BuildAuditFields(nil), }); err != nil { - return 0, err + // A concurrent scoped claim rolls the transaction back; retrying the + // exchange resolves the winning mapping. Do not expose SQL/key data. + return 0, errorsx.UnauthorizedI18n("error.e0161") } return customer.ID, nil } diff --git a/internal/services/customer_session_service.go b/internal/services/customer_session_service.go index 7421b49..14d85da 100644 --- a/internal/services/customer_session_service.go +++ b/internal/services/customer_session_service.go @@ -1,7 +1,11 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( "errors" + "crypto/rand" + "encoding/hex" "strings" "time" @@ -42,10 +46,13 @@ type customerSessionClaims struct { CustomerID int64 `json:"customerId"` CustomerName string `json:"customerName"` IdentityKey string `json:"identityKey"` + IdentityScope string `json:"identityScope"` jwt.RegisteredClaims } type CustomerSessionVerifyResult struct { + CustomerID int64 + ChannelID int64 ExternalUser *openidentity.ExternalUser Token string ExpiresAt time.Time @@ -56,7 +63,18 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser if channel == nil || channel.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0209") } - var customerID int64 + if !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) || (ChannelService.RequiresSignedIdentity(channel) && externalUser.ExternalSource != enums.ExternalSourceUser) { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + externalUser.ChannelID = channel.ID + if externalUser.ExternalSource == enums.ExternalSourceGuest { + // A public caller's guest ID is not proof of possession. Only the signed + // customer session can resume this server-issued anonymous identity. + var id [32]byte + if _, err := rand.Read(id[:]); err != nil { return nil, err } + externalUser.ExternalID = hex.EncodeToString(id[:]) + } + var customerID int64 if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) if err != nil { @@ -68,10 +86,12 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser return nil, err } customer := CustomerService.Get(customerID) - if customer == nil || customer.Status == enums.StatusDeleted { + if customer == nil || customer.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0155") } - token, expiresAt, err := s.Sign(channel, customer, externalUser) + identity, identityErr := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, externalUser.ExternalID) + if identityErr != nil || identity == nil || identity.CustomerID != customer.ID { return nil, errorsx.UnauthorizedI18n("error.e0161") } + token, expiresAt, err := s.Sign(channel, customer, externalUser) if err != nil { return nil, err } @@ -92,7 +112,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models. if secret == "" { return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") } - if channel == nil || customer == nil { + if channel == nil || customer == nil || !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) { return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158") } now := time.Now() @@ -104,6 +124,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models. CustomerID: customer.ID, CustomerName: strings.TrimSpace(customer.Name), IdentityKey: s.identityKey(externalUser), + IdentityScope: externalUser.IdentityScope, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt), @@ -125,27 +146,11 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models if err != nil { return nil, err } - if channel == nil || channel.Status != enums.StatusOk { - return nil, errorsx.InvalidParamI18n("error.e0209") - } - if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) { - return nil, errorsx.UnauthorizedI18n("error.e0161") - } - customer := CustomerService.Get(claims.CustomerID) - if customer == nil || customer.Status == enums.StatusDeleted { - return nil, errorsx.UnauthorizedI18n("error.e0161") - } - external, err := s.externalUserFromClaims(claims, customer) - if err != nil { - return nil, err - } - result := &CustomerSessionVerifyResult{ - ExternalUser: external, - Token: token, - ExpiresAt: claims.ExpiresAt.Time, - } + result, err := s.verifyClaims(claims, channel) + if err != nil { return nil, err } + result.Token = token if s.shouldRefresh(claims.ExpiresAt.Time) { - newToken, expiresAt, err := s.Sign(channel, customer, *external) + newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser) if err != nil { return nil, err } @@ -190,7 +195,7 @@ func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionC if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil { return nil, errorsx.UnauthorizedI18n("error.e0161") } - if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" { + if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" || strings.TrimSpace(claims.IdentityScope) == "" { return nil, errorsx.UnauthorizedI18n("error.e0161") } return claims, nil @@ -211,8 +216,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC default: return nil, errorsx.UnauthorizedI18n("error.e0161") } - identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1]) - if identity == nil || identity.CustomerID != claims.CustomerID { + identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), claims.IdentityScope, source, parts[1]) + if err != nil || identity == nil || identity.CustomerID != claims.CustomerID { return nil, errorsx.UnauthorizedI18n("error.e0161") } name := strings.TrimSpace(claims.CustomerName) @@ -223,6 +228,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC ExternalSource: source, ExternalID: parts[1], ExternalName: name, + IdentityScope: claims.IdentityScope, + ChannelID: claims.ChannelID, }, nil } @@ -250,3 +257,30 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin token, _ := params.Get(ctx, "customerSessionToken") return strings.TrimSpace(token) } + +// VerifyConnection rechecks the signed credential, expiry, channel policy and +// current identity mapping. It never refreshes an idle connection indefinitely. +func (s *customerSessionService) VerifyConnection(result *CustomerSessionVerifyResult) bool { + if result == nil || !time.Now().Before(result.ExpiresAt) { return false } + claims, err := s.verifyToken(result.Token) + if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID { return false } + verified, err := s.verifyClaims(claims, ChannelService.Get(claims.ChannelID)) + return err == nil && verified.ExternalUser != nil && result.ExternalUser != nil && + verified.ExternalUser.IdentityScope == result.ExternalUser.IdentityScope && + verified.ExternalUser.ExternalSource == result.ExternalUser.ExternalSource && + verified.ExternalUser.ExternalID == result.ExternalUser.ExternalID +} + +func (s *customerSessionService) verifyClaims(claims *customerSessionClaims, channel *models.Channel) (*CustomerSessionVerifyResult, error) { + if channel == nil || channel.Status != enums.StatusOk || claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + customer := CustomerService.Get(claims.CustomerID) + if customer == nil || customer.Status != enums.StatusOk { return nil, errorsx.UnauthorizedI18n("error.e0161") } + external, err := s.externalUserFromClaims(claims, customer) + if err != nil { return nil, err } + if !openidentity.ValidIdentityScope(channel.ChannelID, *external) || (ChannelService.RequiresSignedIdentity(channel) && external.ExternalSource != enums.ExternalSourceUser) { + return nil, errorsx.UnauthorizedI18n("error.e0161") + } + return &CustomerSessionVerifyResult{CustomerID: customer.ID, ChannelID: channel.ID, ExternalUser: external, ExpiresAt: claims.ExpiresAt.Time}, nil +} diff --git a/internal/services/setup_readiness_service.go b/internal/services/setup_readiness_service.go index d5abc96..70c6b6c 100644 --- a/internal/services/setup_readiness_service.go +++ b/internal/services/setup_readiness_service.go @@ -1,8 +1,13 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package services import ( + "crypto/sha256" + "encoding/json" + "errors" "fmt" - "math" + "log/slog" + "reflect" "slices" "strconv" "strings" @@ -18,6 +23,8 @@ import ( "agent-desk/internal/pkg/utils" "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) var SetupReadinessService = newSetupReadinessService() @@ -41,7 +48,7 @@ func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*resp db := sqls.DB() var teams []models.AgentTeam - if err := db.Order("id ASC").Find(&teams).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&teams).Error; err != nil { return nil, err } activeTeamMap := make(map[int64]models.AgentTeam) @@ -54,28 +61,28 @@ func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*resp } var schedules []models.AgentTeamSchedule - if err := db.Order("id ASC").Find(&schedules).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&schedules).Error; err != nil { return nil, err } activeScheduleNowTeamSet := make(map[int64]bool) anyScheduleTeamSet := make(map[int64]bool) for _, sch := range schedules { + anyScheduleTeamSet[sch.TeamID] = true if sch.Status != enums.StatusOk { continue } - anyScheduleTeamSet[sch.TeamID] = true if !sch.StartAt.After(now) && sch.EndAt.After(now) { activeScheduleNowTeamSet[sch.TeamID] = true } } var profiles []models.AgentProfile - if err := db.Order("id ASC").Find(&profiles).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&profiles).Error; err != nil { return nil, err } var users []models.User - if err := db.Find(&users).Error; err != nil { + if err := db.Where("id IN ?", extractProfileUserIDs(profiles)).Find(&users).Error; err != nil { return nil, err } activeUserMap := make(map[int64]models.User, len(users)) @@ -91,12 +98,12 @@ func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*resp } var aiAgents []models.AIAgent - if err := db.Order("id ASC").Find(&aiAgents).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&aiAgents).Error; err != nil { return nil, err } var channels []models.Channel - if err := db.Order("id ASC").Find(&channels).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&channels).Error; err != nil { return nil, err } @@ -220,7 +227,7 @@ func (s *setupReadinessService) evaluateSchedulesStep( EntityType: "team", EntityID: team.ID, EntityName: team.Name, - QuickFixAction: QuickFixActionEnsureTeamSchedule, + }) } @@ -229,15 +236,15 @@ func (s *setupReadinessService) evaluateSchedulesStep( if len(activeTeamMap) == 0 { status = "error" summaryCode = "no_active_team" - quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } else if readyTeamCount == 0 { status = "error" summaryCode = "no_schedule_now" - quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } else if len(issues) > 0 { status = "warning" summaryCode = "partial_schedule_now" - quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule) + } return response.SetupStepItem{ @@ -400,6 +407,10 @@ func (s *setupReadinessService) evaluateAIAgentsStep( continue } activeAgentCount++ + if agent.PublishedRevisionID <= 0 { + issues = append(issues, response.SetupIssueItem{Code: "agent_unpublished", Severity: "error", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name}) + continue + } if agent.ServiceMode == enums.IMConversationServiceModeAIOnly { issues = append(issues, response.SetupIssueItem{ Code: "agent_ai_only_mode", @@ -537,6 +548,10 @@ func (s *setupReadinessService) evaluateChannelsStep( }) continue } + if agentMap[ch.AIAgentID].PublishedRevisionID <= 0 { + issues = append(issues, response.SetupIssueItem{Code: "channel_agent_unpublished", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name}) + continue + } if ch.AIAgentRolloutPercent <= 0 { issues = append(issues, response.SetupIssueItem{ Code: "channel_zero_rollout", @@ -714,6 +729,9 @@ func (s *setupReadinessService) evaluateCurrentOperator( } func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error) { + if req.BypassAI { + return nil, errorsx.InvalidParamI18n("error.setup.bypassUnsupported") + } now := time.Now() db := sqls.DB() fixIfAllowed := func(action string) string { @@ -725,8 +743,10 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ var channel models.Channel if req.ChannelID > 0 { - if err := db.First(&channel, req.ChannelID).Error; err != nil { + if err := db.Where("status <> ?", enums.StatusDeleted).First(&channel, req.ChannelID).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) } return &response.SetupSimulateRouteResponse{ + CandidateOperators: []response.SimulatedOperatorCandidate{}, WillReachOperator: false, FinalOutcomeCode: "blocked_no_channel", Hops: []response.SimulatedRouteHop{ @@ -741,8 +761,11 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ } } else { if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil { - if err2 := db.Order("id ASC").First(&channel).Error; err2 != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err) } + if err2 := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").First(&channel).Error; err2 != nil { + if !errors.Is(err2, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(err2) } return &response.SetupSimulateRouteResponse{ + CandidateOperators: []response.SimulatedOperatorCandidate{}, WillReachOperator: false, FinalOutcomeCode: "blocked_no_channel", Hops: []response.SimulatedRouteHop{ @@ -791,6 +814,11 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ }) return resp, nil } + if channel.AIAgentRolloutPercent < 100 { + resp.FinalOutcomeCode = "partial_rollout_not_simulated" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "channel", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: channel.ID, EntityName: channel.Name}) + return resp, nil + } if channel.AIAgentID <= 0 { resp.FinalOutcomeCode = "blocked_missing_ai_agent" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ @@ -813,7 +841,9 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ // Hop 2: AI Agent var aiAgent models.AIAgent - if err := db.First(&aiAgent, channel.AIAgentID).Error; err != nil || aiAgent.Status != enums.StatusOk { + agentErr := db.First(&aiAgent, channel.AIAgentID).Error + if agentErr != nil && !errors.Is(agentErr, gorm.ErrRecordNotFound) { return nil, setupPersistenceError(agentErr) } + if agentErr != nil || aiAgent.Status != enums.StatusOk { resp.FinalOutcomeCode = "blocked_missing_ai_agent" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ HopKey: "ai_agent", @@ -824,6 +854,11 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ }) return resp, nil } + if aiAgent.PublishedRevisionID <= 0 { + resp.FinalOutcomeCode = "blocked_agent_unpublished" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "error", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name}) + return resp, nil + } if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly { resp.FinalOutcomeCode = "blocked_ai_only_mode" resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ @@ -836,29 +871,18 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ }) return resp, nil } - agentHopStatus := "ok" - agentReason := "ok_human_only" - if aiAgent.ServiceMode == enums.IMConversationServiceModeAIFirst { - agentReason = "ok_ai_first_handoff" - if !aiAgent.AutoHandoffOnFailure { - agentHopStatus = "warning" - agentReason = "warning_auto_handoff_off" - } - } - resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ - HopKey: "ai_agent", - Status: agentHopStatus, - ReasonCode: agentReason, - EntityID: aiAgent.ID, - EntityName: aiAgent.Name, - QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff), - }) + if aiAgent.ServiceMode != enums.IMConversationServiceModeHumanOnly { + resp.FinalOutcomeCode = "ai_execution_not_simulated" + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name}) + return resp, nil + } + resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "ok", ReasonCode: "ok_human_only", EntityID: aiAgent.ID, EntityName: aiAgent.Name}) // Hop 3: Target Teams requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs) var teams []models.AgentTeam if len(requestedTeamIDs) > 0 { - db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams) + if err := db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams).Error; err != nil { return nil, setupPersistenceError(err) } } if len(teams) == 0 { resp.FinalOutcomeCode = "blocked_agent_no_teams" @@ -889,7 +913,8 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ }) // Hop 4: Shift Schedules (Active Now) - activeScheduleTeamIDs := ConversationDispatchService.findActiveScheduleTeamIDs(enabledTeamIDs, now) + activeScheduleTeamIDs := make([]int64, 0) + if err := db.Model(&models.AgentTeamSchedule{}).Distinct("team_id").Where("team_id IN ? AND status = ? AND start_at <= ? AND end_at > ?", enabledTeamIDs, enums.StatusOk, now, now).Pluck("team_id", &activeScheduleTeamIDs).Error; err != nil { return nil, setupPersistenceError(err) } activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs)) activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs)) for _, tid := range activeScheduleTeamIDs { @@ -906,9 +931,11 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ Status: "error", ReasonCode: "queued_off_hours_no_schedule", EntityName: strings.Join(teamNames, ", "), - QuickFixAction: fixIfAllowed(QuickFixActionEnsureTeamSchedule), + }) - resp.CandidateOperators = s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + if err != nil { return nil, err } + resp.CandidateOperators = candidates return resp, nil } resp.Hops = append(resp.Hops, response.SimulatedRouteHop{ @@ -919,7 +946,8 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ }) // Hop 5: Operator Candidate Selection - candidates := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet) + if err != nil { return nil, err } resp.CandidateOperators = candidates eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now) @@ -971,26 +999,19 @@ func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequ func (s *setupReadinessService) buildCandidateListForTeams( enabledTeamIDSet map[int64]models.AgentTeam, activeScheduleTeamSet map[int64]bool, -) []response.SimulatedOperatorCandidate { +) ([]response.SimulatedOperatorCandidate, error) { db := sqls.DB() - var allProfiles []models.AgentProfile - db.Order("id ASC").Find(&allProfiles) - - var allTeams []models.AgentTeam - db.Find(&allTeams) - teamNameMap := make(map[int64]string, len(allTeams)) - for _, t := range allTeams { - teamNameMap[t.ID] = t.Name - } - - var users []models.User - db.Find(&users) - userMap := make(map[int64]models.User, len(users)) - for _, u := range users { - userMap[u.ID] = u - } - - activeCounts, _ := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles)) + teamIDs := make([]int64, 0, len(enabledTeamIDSet)) + teamNameMap := make(map[int64]string, len(enabledTeamIDSet)) + for id, team := range enabledTeamIDSet { teamIDs = append(teamIDs, id); teamNameMap[id] = team.Name } + var allProfiles []models.AgentProfile + if err := db.Where("team_id IN ? AND status <> ?", teamIDs, enums.StatusDeleted).Order("id ASC").Find(&allProfiles).Error; err != nil { return nil, setupPersistenceError(err) } + var users []models.User + if err := db.Where("id IN ?", extractProfileUserIDs(allProfiles)).Find(&users).Error; err != nil { return nil, setupPersistenceError(err) } + userMap := make(map[int64]models.User, len(users)) + for _, u := range users { userMap[u.ID] = u } + activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles)) + if err != nil { return nil, setupPersistenceError(err) } result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles)) for _, p := range allProfiles { @@ -1034,16 +1055,13 @@ func (s *setupReadinessService) buildCandidateListForTeams( AutoAssignEnabled: p.AutoAssignEnabled, }) } - return result + return result, nil } func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, codes ...string) bool { if operator == nil || operator.UserID <= 0 { return false } - if slices.Contains(operator.Roles, constants.RoleCodeSuperAdmin) || slices.Contains(operator.Roles, constants.RoleCodeAdmin) { - return true - } for _, code := range codes { if slices.Contains(operator.Permissions, code) { return true @@ -1053,8 +1071,7 @@ func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, co } func (s *setupReadinessService) canManageTeamsAndSchedules(operator *dto.AuthPrincipal) bool { - return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code) && - s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code) + return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code) } func (s *setupReadinessService) canManageOperatorProfile(operator *dto.AuthPrincipal) bool { @@ -1112,494 +1129,264 @@ func (s *setupReadinessService) filterStepQuickFixes(step response.SetupStepItem return step } +// A quick fix is a two-phase operation. Omitted apply intent is read-only, even +// for older clients. The fingerprint binds approval to the principal, named +// targets, current configuration, and exact proposed field changes. +type setupQuickFixPlan struct { + changes []response.SetupQuickFixChange + warnings []string + operations []func(*gorm.DB) error + snapshot any +} + func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error) { if operator == nil || operator.UserID <= 0 { return nil, errorsx.UnauthorizedI18n("error.setup.authRequired") } - - action := strings.TrimSpace(req.Action) - applied := make([]string, 0, 4) - - switch action { - case QuickFixActionQuickSetupAll: - if !s.canRunQuickFixAction(operator, QuickFixActionQuickSetupAll) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - a1, err := s.ensureTeamAndSchedule(operator) - if err != nil { - return nil, err - } - applied = append(applied, a1...) - - a2, err := s.enableOperatorProfile(operator) - if err != nil { - return nil, err - } - applied = append(applied, a2...) - - a3, err := s.fixAIAgentHandoff(req.EntityID, operator) - if err != nil { - return nil, err - } - applied = append(applied, a3...) - - a4, err := s.ensureDefaultChannel(req.EntityID, operator) - if err != nil { - return nil, err - } - applied = append(applied, a4...) - - case QuickFixActionEnsureTeamSchedule: - if !s.canRunQuickFixAction(operator, QuickFixActionEnsureTeamSchedule) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - a1, err := s.ensureTeamAndSchedule(operator) - if err != nil { - return nil, err - } - applied = append(applied, a1...) - - case QuickFixActionEnableMyProfile: - if !s.canRunQuickFixAction(operator, QuickFixActionEnableMyProfile) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - if s.canManageTeamsAndSchedules(operator) { - a1, err := s.ensureTeamAndSchedule(operator) - if err != nil { - return nil, err - } - applied = append(applied, a1...) - } - - a2, err := s.enableOperatorProfile(operator) - if err != nil { - return nil, err - } - applied = append(applied, a2...) - - case QuickFixActionFixAIAgentHandoff: - if !s.canRunQuickFixAction(operator, QuickFixActionFixAIAgentHandoff) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - if s.canManageTeamsAndSchedules(operator) { - a1, err := s.ensureTeamAndSchedule(operator) - if err != nil { - return nil, err - } - applied = append(applied, a1...) - } - - a2, err := s.fixAIAgentHandoff(req.EntityID, operator) - if err != nil { - return nil, err - } - applied = append(applied, a2...) - - case QuickFixActionEnsureDefaultChannel: - if !s.canRunQuickFixAction(operator, QuickFixActionEnsureDefaultChannel) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - if s.canManageTeamsAndSchedules(operator) { - a1, err := s.ensureTeamAndSchedule(operator) - if err != nil { - return nil, err - } - applied = append(applied, a1...) - } - if s.canManageAIAgents(operator) { - a2, err := s.fixAIAgentHandoff(0, operator) - if err != nil { - return nil, err - } - applied = append(applied, a2...) - } - - a3, err := s.ensureDefaultChannel(req.EntityID, operator) - if err != nil { - return nil, err - } - applied = append(applied, a3...) - + req.Action = strings.TrimSpace(req.Action) + switch req.Action { + case QuickFixActionQuickSetupAll, QuickFixActionEnsureTeamSchedule, QuickFixActionEnableMyProfile, QuickFixActionFixAIAgentHandoff, QuickFixActionEnsureDefaultChannel: default: - return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", action) + return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", req.Action) + } + if req.EntityID < 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } + if !s.canRunQuickFixAction(operator, req.Action) { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + if req.Apply && strings.TrimSpace(req.PreviewToken) == "" { + return nil, errorsx.InvalidParamI18n("error.setup.previewRequired") + } + res := &response.SetupQuickFixResponse{ + AppliedActions: []string{}, Changes: []response.SetupQuickFixChange{}, WarningCodes: []string{}, + } + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + db := ctx.Tx + if req.Apply { + // SQLite serializes writes; MySQL locks the configuration rows/ranges. + db = db.Clauses(clause.Locking{Strength: "UPDATE"}) + } + plan, err := s.planQuickFix(db, req, operator) + if err != nil { + return err + } + encoded, err := json.Marshal([]any{operator.UserID, req.Action, req.EntityID, plan.changes, plan.snapshot}) + if err != nil { + return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed") + } + res.PreviewToken = fmt.Sprintf("%x", sha256.Sum256(encoded)) + res.Changes = plan.changes + res.WarningCodes = plan.warnings + if !req.Apply { + return nil + } + if req.PreviewToken != res.PreviewToken { + return errorsx.InvalidParamI18n("error.setup.previewChanged") + } + for i, operation := range plan.operations { + if err := operation(ctx.Tx); err != nil { + return err + } + res.AppliedActions = append(res.AppliedActions, plan.changes[i].Operation+"_"+plan.changes[i].EntityType) + } + return nil + }) + if err != nil { + var appError *errorsx.I18nError + if errors.As(err, &appError) { return nil, err } + return nil, setupPersistenceError(err) + } + res.Applied = req.Apply + // Dispatch is deliberately outside the configuration transaction. A failure + // does not roll back committed repairs and is visible to the caller. + if res.Applied && len(res.AppliedActions) > 0 { + if _, err := ConversationDispatchService.DispatchPendingConversations(0); err != nil { + slog.Warn("setup repair committed but pending dispatch failed", "user_id", operator.UserID) + res.WarningCodes = append(res.WarningCodes, "dispatch_failed") + } } - - // Trigger dispatch of any pending conversations now that routing is fixed - _, _ = ConversationDispatchService.DispatchPendingConversations(0) - readiness, err := s.GetReadiness(operator) if err != nil { - return nil, err + // Never report a committed mutation as a failed write that invites a retry. + res.WarningCodes = append(res.WarningCodes, "readiness_refresh_failed") + } else { + res.Readiness = readiness } - - return &response.SetupQuickFixResponse{ - AppliedActions: applied, - Readiness: *readiness, - }, nil + return res, nil } -func (s *setupReadinessService) ensureTeamAndSchedule(operator *dto.AuthPrincipal) ([]string, error) { - now := time.Now() - db := sqls.DB() - applied := make([]string, 0, 2) - - var activeTeams []models.AgentTeam - if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil { - return nil, err - } - - if len(activeTeams) == 0 { - var anyTeam models.AgentTeam - if err := db.Order("id ASC").First(&anyTeam).Error; err == nil && anyTeam.ID > 0 { - if !s.hasAnyPermission(operator, constants.PermissionAgentTeamUpdate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - anyTeam.Status = enums.StatusOk - anyTeam.UpdatedAt = now - anyTeam.UpdateUserID = operator.UserID - anyTeam.UpdateUserName = operator.Username - if err := db.Save(&anyTeam).Error; err != nil { - return nil, err - } - activeTeams = append(activeTeams, anyTeam) - applied = append(applied, "enabled_team") - } else { - if !s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - newTeam := models.AgentTeam{ - Name: "Default Support Team", - LeaderUserID: operator.UserID, - Status: enums.StatusOk, - Description: "Auto-created support team", - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: operator.UserID, - CreateUserName: operator.Username, - UpdatedAt: now, - UpdateUserID: operator.UserID, - UpdateUserName: operator.Username, - }, - } - if err := db.Create(&newTeam).Error; err != nil { - return nil, err - } - activeTeams = append(activeTeams, newTeam) - applied = append(applied, "created_default_team") - } - } - - for _, team := range activeTeams { - var count int64 - if err := db.Model(&models.AgentTeamSchedule{}). - Where("team_id = ? AND status = ? AND start_at <= ? AND end_at > ?", team.ID, enums.StatusOk, now, now). - Count(&count).Error; err != nil { - return nil, err - } - if count == 0 { - if !s.hasAnyPermission(operator, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleBatchGenerate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - schedule := models.AgentTeamSchedule{ - TeamID: team.ID, - StartAt: now.Add(-1 * time.Hour), - EndAt: now.AddDate(1, 0, 0), - Remark: "24/7 Continuous Shift (Auto-Setup)", - Status: enums.StatusOk, - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: operator.UserID, - CreateUserName: operator.Username, - UpdatedAt: now, - UpdateUserID: operator.UserID, - UpdateUserName: operator.Username, - }, - } - if err := db.Create(&schedule).Error; err != nil { - return nil, err - } - applied = append(applied, "created_active_schedule") - } - } - - return applied, nil +func setupPersistenceError(err error) error { + if err == nil { return nil } + return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed") } -func (s *setupReadinessService) enableOperatorProfile(operator *dto.AuthPrincipal) ([]string, error) { - now := time.Now() - db := sqls.DB() - applied := make([]string, 0, 1) - - var firstTeam models.AgentTeam - if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstTeam).Error; err != nil { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - - var profile models.AgentProfile - err := db.Where("user_id = ?", operator.UserID).First(&profile).Error - if err != nil { - if !s.hasAnyPermission(operator, constants.PermissionAgentCreate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - displayName := firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)) - newProfile := models.AgentProfile{ - UserID: operator.UserID, - TeamID: firstTeam.ID, - AgentCode: fmt.Sprintf("AG%04d", operator.UserID), - DisplayName: displayName, - ServiceStatus: enums.ServiceStatusIdle, - MaxConcurrentCount: 10, - PriorityLevel: 1, - AutoAssignEnabled: true, - ReceiveOfflineMessage: true, - LastOnlineAt: &now, - LastStatusAt: &now, - Status: enums.StatusOk, - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: operator.UserID, - CreateUserName: operator.Username, - UpdatedAt: now, - UpdateUserID: operator.UserID, - UpdateUserName: operator.Username, - }, - } - if err := db.Create(&newProfile).Error; err != nil { - return nil, err - } - applied = append(applied, "created_operator_profile") - return applied, nil - } - - targetTeamID := profile.TeamID - var teamCheck models.AgentTeam - needsTeamRebind := targetTeamID <= 0 || db.Where("id = ? AND status = ?", targetTeamID, enums.StatusOk).First(&teamCheck).Error != nil - if needsTeamRebind { - targetTeamID = firstTeam.ID - } - - maxConcurrent := profile.MaxConcurrentCount - needsConfigChange := !profile.AutoAssignEnabled || maxConcurrent <= 0 - if maxConcurrent <= 0 { - maxConcurrent = 10 - } - needsProfileEnable := profile.Status != enums.StatusOk - - if (needsTeamRebind || needsProfileEnable) && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - if needsConfigChange && !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - if !s.hasAnyPermission(operator, constants.PermissionAgentUpdate.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - - updates := map[string]any{ - "team_id": targetTeamID, - "service_status": enums.ServiceStatusIdle, - "auto_assign_enabled": true, - "max_concurrent_count": maxConcurrent, - "status": enums.StatusOk, - "last_online_at": now, - "last_status_at": now, - "updated_at": now, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - } - if err := db.Model(&models.AgentProfile{}).Where("id = ?", profile.ID).Updates(updates).Error; err != nil { - return nil, err - } - applied = append(applied, "updated_operator_profile_online") - return applied, nil -} - -func (s *setupReadinessService) fixAIAgentHandoff(targetAgentID int64, operator *dto.AuthPrincipal) ([]string, error) { - now := time.Now() - db := sqls.DB() - applied := make([]string, 0, 2) - - var activeTeams []models.AgentTeam - if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").Find(&activeTeams).Error; err != nil { - return nil, err - } - teamIDStrs := make([]string, 0, len(activeTeams)) - activeTeamSet := make(map[int64]bool, len(activeTeams)) - for _, t := range activeTeams { - teamIDStrs = append(teamIDStrs, strconv.FormatInt(t.ID, 10)) - activeTeamSet[t.ID] = true - } - defaultTeamIDsCSV := strings.Join(teamIDStrs, ",") - +func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*setupQuickFixPlan, error) { + plan := &setupQuickFixPlan{changes: []response.SetupQuickFixChange{}, warnings: []string{}, operations: []func(*gorm.DB) error{}} + var teams []models.AgentTeam var agents []models.AIAgent - if targetAgentID > 0 { - db.Where("id = ?", targetAgentID).Find(&agents) - } else { - db.Order("id ASC").Find(&agents) - } - - if len(agents) == 0 { - if !s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - newAgent := models.AIAgent{ - Name: "Default Reception Agent", - Description: "Routes customer inquiries to human operators", - Status: enums.StatusOk, - ServiceMode: enums.IMConversationServiceModeHumanOnly, - RolloutPercent: 100, - TeamIDs: defaultTeamIDsCSV, - HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, - FallbackMode: enums.AIAgentFallbackModeHandoff, - AutoHandoffOnFailure: true, - ReplyTimeoutSeconds: 180, - MaxSteps: 6, - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: operator.UserID, - CreateUserName: operator.Username, - UpdatedAt: now, - UpdateUserID: operator.UserID, - UpdateUserName: operator.Username, - }, - } - if err := db.Create(&newAgent).Error; err != nil { - return nil, err - } - applied = append(applied, "created_default_ai_agent") - return applied, nil - } - - if !s.hasAnyPermission(operator, constants.PermissionAIAgentUpdate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - - for _, agent := range agents { - existingTeamIDs := utils.SplitInt64s(agent.TeamIDs) - validTeamIDs := make([]string, 0, len(existingTeamIDs)) - for _, tid := range existingTeamIDs { - if activeTeamSet[tid] { - validTeamIDs = append(validTeamIDs, strconv.FormatInt(tid, 10)) - } - } - newTeamCSV := strings.Join(validTeamIDs, ",") - if newTeamCSV == "" { - newTeamCSV = defaultTeamIDsCSV - } - - hasValidAIConfig := false - if agent.AIConfigID > 0 { - var aiCfg models.AIConfig - if err := db.Where("id = ? AND status = ?", agent.AIConfigID, enums.StatusOk).First(&aiCfg).Error; err == nil && aiCfg.ID > 0 { - hasValidAIConfig = true - } - } - - newServiceMode := agent.ServiceMode - if !hasValidAIConfig { - newServiceMode = enums.IMConversationServiceModeHumanOnly - } else if newServiceMode == enums.IMConversationServiceModeAIOnly || newServiceMode == 0 { - newServiceMode = enums.IMConversationServiceModeAIFirst - } - - rollout := agent.RolloutPercent - if rollout <= 0 { - rollout = 100 - } - - updates := map[string]any{ - "status": enums.StatusOk, - "team_ids": newTeamCSV, - "service_mode": newServiceMode, - "auto_handoff_on_failure": true, - "rollout_percent": rollout, - "updated_at": now, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - } - if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(updates).Error; err != nil { - return nil, err - } - applied = append(applied, "updated_ai_agent_handoff") - } - - return applied, nil -} - -func (s *setupReadinessService) ensureDefaultChannel(targetChannelID int64, operator *dto.AuthPrincipal) ([]string, error) { - now := time.Now() - db := sqls.DB() - applied := make([]string, 0, 1) - - var firstAgent models.AIAgent - if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&firstAgent).Error; err != nil { - return nil, errorsx.ForbiddenI18n("error.e0225") - } - var channels []models.Channel - if targetChannelID > 0 { - db.Where("id = ?", targetChannelID).Find(&channels) - } else { - db.Order("id ASC").Find(&channels) + var profiles []models.AgentProfile + // Deleted records are never update candidates. Keep them in the snapshot so + // deletion cannot silently turn an approved update into a replacement create. + // Each read gets a fresh statement; the shared handle may carry a row lock, + // but must not retain the previous Find model/table or profile filter. + for _, dest := range []any{&teams, &agents, &channels} { + if err := db.Session(&gorm.Session{}).Order("id ASC").Find(dest).Error; err != nil { return nil, setupPersistenceError(err) } + } + if err := db.Session(&gorm.Session{}).Where("user_id = ?", operator.UserID).Order("id ASC").Find(&profiles).Error; err != nil { return nil, setupPersistenceError(err) } + if req.Action == QuickFixActionEnableMyProfile && req.EntityID > 0 && (len(profiles) == 0 || profiles[0].ID != req.EntityID) { + return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") + } + plan.snapshot = []any{teams, agents, channels, profiles} + now := time.Now() + audit := models.AuditFields{CreatedAt: now, UpdatedAt: now, CreateUserID: operator.UserID, UpdateUserID: operator.UserID, CreateUserName: operator.Username, UpdateUserName: operator.Username} + require := func(codes ...string) error { + if !s.hasAnyPermission(operator, codes...) { return errorsx.ForbiddenI18n("error.e0225") } + return nil + } + add := func(kind string, id int64, name, operation string, before, after map[string]any, run func(*gorm.DB) error) { + plan.changes = append(plan.changes, response.SetupQuickFixChange{EntityType: kind, EntityID: id, EntityName: name, Operation: operation, Before: before, After: after}) + plan.operations = append(plan.operations, run) + } + update := func(model any, kind string, id int64, name string, before, after map[string]any, updatedAt time.Time) { + for field, value := range after { + if reflect.DeepEqual(before[field], value) { delete(before, field); delete(after, field) } + } + if len(after) == 0 { return } + add(kind, id, name, "update", before, after, func(tx *gorm.DB) error { + updates := make(map[string]any, len(after)+3) + for key, value := range after { updates[key] = value } + updates["updated_at"] = now + updates["update_user_id"] = operator.UserID + updates["update_user_name"] = operator.Username + result := tx.Model(model).Where("id = ? AND status <> ? AND updated_at = ?", id, enums.StatusDeleted, updatedAt).Updates(updates) + if result.Error != nil { return setupPersistenceError(result.Error) } + if result.RowsAffected != 1 { return errorsx.InvalidParamI18n("error.setup.previewChanged") } + return nil + }) + } + all := req.Action == QuickFixActionQuickSetupAll + if all && req.EntityID != 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } + + var team *models.AgentTeam + nonDeletedTeams := 0 + for i := range teams { + if teams[i].Status != enums.StatusDeleted { nonDeletedTeams++ } + if team == nil && teams[i].Status == enums.StatusOk { team = &teams[i] } + } + if req.Action == QuickFixActionEnsureTeamSchedule && req.EntityID > 0 { + team = nil + for i := range teams { + if teams[i].ID == req.EntityID && teams[i].Status == enums.StatusOk { team = &teams[i] } + } + if team == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } + } + if (all || req.Action == QuickFixActionEnsureTeamSchedule) && team == nil && nonDeletedTeams == 0 && req.EntityID == 0 { + if err := require(constants.PermissionAgentTeamCreate.Code); err != nil { return nil, err } + team = &models.AgentTeam{Name: "Default Support Team", LeaderUserID: operator.UserID, Status: enums.StatusOk, AuditFields: audit} + add("team", 0, team.Name, "create", nil, map[string]any{"name": team.Name, "leader_user_id": operator.UserID, "status": enums.StatusOk}, func(tx *gorm.DB) error { return setupPersistenceError(tx.Create(team).Error) }) + } + // A team being off duty is not a broken schedule. The existing schedule + // editor is the only place to add explicit hours; quick fixes never add shifts. + if all || req.Action == QuickFixActionEnsureTeamSchedule { plan.warnings = append(plan.warnings, "schedules_unchanged") } + if team == nil { plan.warnings = append(plan.warnings, "active_team_required") } + teamRef := func() any { + if team == nil { return nil } + if team.ID > 0 { return team.ID } + return team.Name } - if len(channels) == 0 { - if !s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") + if all || req.Action == QuickFixActionEnableMyProfile { + if len(profiles) == 0 && team != nil { + if err := require(constants.PermissionAgentCreate.Code); err != nil { return nil, err } + profile := &models.AgentProfile{UserID: operator.UserID, AgentCode: fmt.Sprintf("AG%04d", operator.UserID), DisplayName: firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)), ServiceStatus: enums.ServiceStatusIdle, MaxConcurrentCount: 10, PriorityLevel: 1, AutoAssignEnabled: true, ReceiveOfflineMessage: true, Status: enums.StatusOk, AuditFields: audit} + add("operator", 0, profile.DisplayName, "create", nil, map[string]any{"user_id": operator.UserID, "team_id": teamRef(), "status": enums.StatusOk, "service_status": enums.ServiceStatusIdle, "auto_assign_enabled": true, "max_concurrent_count": 10, "priority_level": 1, "receive_offline_message": true}, func(tx *gorm.DB) error { + profile.TeamID = team.ID + return setupPersistenceError(tx.Create(profile).Error) + }) + } else if !all && len(profiles) > 0 { + profile := profiles[0] + if profile.Status == enums.StatusDeleted { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } + before, after := map[string]any{}, map[string]any{} + if profile.Status != enums.StatusOk { + if err := require(constants.PermissionAgentUpdate.Code); err != nil { return nil, err } + before["status"], after["status"] = profile.Status, enums.StatusOk + } + if profile.ServiceStatus != enums.ServiceStatusIdle { + if err := require(constants.PermissionAgentUpdateStatus.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err } + before["service_status"], after["service_status"] = profile.ServiceStatus, enums.ServiceStatusIdle + } + if !profile.AutoAssignEnabled || profile.MaxConcurrentCount <= 0 { + if err := require(constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdate.Code); err != nil { return nil, err } + if !profile.AutoAssignEnabled { before["auto_assign_enabled"], after["auto_assign_enabled"] = false, true } + if profile.MaxConcurrentCount <= 0 { before["max_concurrent_count"], after["max_concurrent_count"] = profile.MaxConcurrentCount, 10 } + } + // Do not silently move an existing operator to a different team. + update(&models.AgentProfile{}, "operator", profile.ID, profile.DisplayName, before, after, profile.UpdatedAt) } - newChannel := models.Channel{ - Name: "Default Web Support Channel", - ChannelType: string(enums.ChannelTypeWeb), - ChannelID: fmt.Sprintf("web_default_%d", now.Unix()), - AIAgentID: firstAgent.ID, - AIAgentRolloutPercent: 100, - ConfigJSON: `{"position":"right"}`, - Status: enums.StatusOk, - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: operator.UserID, - CreateUserName: operator.Username, - UpdatedAt: now, - UpdateUserID: operator.UserID, - UpdateUserName: operator.Username, - }, - } - if err := db.Create(&newChannel).Error; err != nil { - return nil, err - } - applied = append(applied, "created_default_channel") - return applied, nil } - if !s.hasAnyPermission(operator, constants.PermissionChannelUpdate.Code) { - return nil, errorsx.ForbiddenI18n("error.e0225") + var agent *models.AIAgent + nonDeletedAgents := 0 + for i := range agents { + if agents[i].Status != enums.StatusDeleted { nonDeletedAgents++ } + if agent == nil && agents[i].Status == enums.StatusOk && agents[i].RolloutPercent > 0 && agents[i].PublishedRevisionID > 0 { agent = &agents[i] } + } + if all || req.Action == QuickFixActionFixAIAgentHandoff { + if nonDeletedAgents == 0 && req.EntityID == 0 && team != nil { + if err := require(constants.PermissionAIAgentCreate.Code); err != nil { return nil, err } + agent = &models.AIAgent{Name: "Default Reception Agent", Status: enums.StatusOk, ServiceMode: enums.IMConversationServiceModeHumanOnly, RolloutPercent: 100, HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, FallbackMode: enums.AIAgentFallbackModeHandoff, AutoHandoffOnFailure: true, ReplyTimeoutSeconds: 180, MaxSteps: 6, AuditFields: audit} + add("ai_agent", 0, agent.Name, "create", nil, map[string]any{"name": agent.Name, "status": enums.StatusOk, "team_ids": teamRef(), "service_mode": agent.ServiceMode, "rollout_percent": 100, "handoff_mode": agent.HandoffMode, "fallback_mode": agent.FallbackMode, "auto_handoff_on_failure": true, "reply_timeout_seconds": 180, "max_steps": 6}, func(tx *gorm.DB) error { + agent.TeamIDs = strconv.FormatInt(team.ID, 10) + return setupPersistenceError(tx.Create(agent).Error) + }) + } else if !all { + if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } + var target *models.AIAgent + for i := range agents { if agents[i].ID == req.EntityID && agents[i].Status != enums.StatusDeleted { target = &agents[i] } } + if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } + if target.Status != enums.StatusOk || target.RolloutPercent <= 0 { + plan.warnings = append(plan.warnings, "disabled_configuration_preserved") + } else { + if err := require(constants.PermissionAIAgentUpdate.Code); err != nil { return nil, err } + before, after := map[string]any{}, map[string]any{} + if !target.AutoHandoffOnFailure { before["auto_handoff_on_failure"], after["auto_handoff_on_failure"] = false, true } + if target.ServiceMode == enums.IMConversationServiceModeAIOnly { before["service_mode"], after["service_mode"] = target.ServiceMode, enums.IMConversationServiceModeAIFirst } + // Team membership and AI configuration need deliberate selection in + // the existing editor, not expansion to every active team. + if len(utils.SplitInt64s(target.TeamIDs)) == 0 { plan.warnings = append(plan.warnings, "agent_team_selection_required") } + update(&models.AIAgent{}, "ai_agent", target.ID, target.Name, before, after, target.UpdatedAt) + } + } } - for _, ch := range channels { - agentID := ch.AIAgentID - var checkAgent models.AIAgent - if agentID <= 0 || db.Where("id = ? AND status = ?", agentID, enums.StatusOk).First(&checkAgent).Error != nil { - agentID = firstAgent.ID + if all || req.Action == QuickFixActionEnsureDefaultChannel { + nonDeletedChannels := 0 + for _, channel := range channels { if channel.Status != enums.StatusDeleted { nonDeletedChannels++ } } + if nonDeletedChannels == 0 && req.EntityID == 0 && agent != nil && agent.PublishedRevisionID > 0 { + if err := require(constants.PermissionChannelCreate.Code); err != nil { return nil, err } + channel := &models.Channel{Name: "Default Web Support Channel", ChannelType: string(enums.ChannelTypeWeb), ChannelID: fmt.Sprintf("web_setup_%d_%d", operator.UserID, len(channels)+1), AIAgentRolloutPercent: 100, ConfigJSON: `{"position":"right"}`, Status: enums.StatusOk, AuditFields: audit} + agentRef := any(agent.ID) + if agent.ID == 0 { agentRef = agent.Name } + add("channel", 0, channel.Name, "create", nil, map[string]any{"name": channel.Name, "channel_id": channel.ChannelID, "channel_type": channel.ChannelType, "ai_agent_id": agentRef, "status": enums.StatusOk, "ai_agent_rollout_percent": 100, "config_json": channel.ConfigJSON}, func(tx *gorm.DB) error { + channel.AIAgentID = agent.ID + return setupPersistenceError(tx.Create(channel).Error) + }) + } else if nonDeletedChannels == 0 && req.EntityID == 0 { + // Match the normal channel editor's publication guard. Publishing an + // agent is a separate reviewed workflow, never implicit in setup. + plan.warnings = append(plan.warnings, "agent_publication_required") + } else if !all { + if req.EntityID <= 0 { return nil, errorsx.InvalidParamI18n("error.setup.targetRequired") } + var target *models.Channel + for i := range channels { if channels[i].ID == req.EntityID && channels[i].Status != enums.StatusDeleted { target = &channels[i] } } + if target == nil { return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable") } + // Never replace an explicitly configured channel route or rollout with + // the first agent in the database. Use the existing channel editor. + plan.warnings = append(plan.warnings, "channel_selection_required") } - rollout := ch.AIAgentRolloutPercent - if rollout <= 0 { - rollout = 100 - } - updates := map[string]any{ - "status": enums.StatusOk, - "ai_agent_id": agentID, - "ai_agent_rollout_percent": rollout, - "updated_at": now, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - } - if err := db.Model(&models.Channel{}).Where("id = ?", ch.ID).Updates(updates).Error; err != nil { - return nil, err - } - applied = append(applied, "updated_channel_routing") } - - return applied, nil + if all { plan.warnings = append(plan.warnings, "existing_configuration_preserved") } + return plan, nil } func extractProfileUserIDs(profiles []models.AgentProfile) []int64 { @@ -1623,8 +1410,3 @@ func firstNonEmpty(values ...string) string { } return "" } - -var ( - _ = math.Max - _ = slices.SortFunc[[]int, int] -) diff --git a/internal/services/setup_readiness_service_test.go b/internal/services/setup_readiness_service_test.go index 8ecc130..cb7f67b 100644 --- a/internal/services/setup_readiness_service_test.go +++ b/internal/services/setup_readiness_service_test.go @@ -1,6 +1,10 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. package services_test import ( + "encoding/json" + "errors" + "slices" "strings" "testing" "time" @@ -9,6 +13,7 @@ import ( "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/services" @@ -59,378 +64,274 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB { return db } -func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) { - db := setupSetupReadinessTestDB(t) - - admin := models.User{ - Username: "admin", - Nickname: "Admin Operator", - Status: enums.StatusOk, - } - if err := db.Create(&admin).Error; err != nil { - t.Fatalf("create admin error = %v", err) - } - principal := &dto.AuthPrincipal{ - UserID: admin.ID, - Username: admin.Username, - Nickname: admin.Nickname, - Roles: []string{constants.RoleCodeSuperAdmin}, - } - - readiness, err := services.SetupReadinessService.GetReadiness(principal) - if err != nil { - t.Fatalf("GetReadiness error = %v", err) - } - if readiness.CanReceiveRequestsNow { - t.Fatalf("expected CanReceiveRequestsNow = false on empty DB") - } - if readiness.ReadySteps != 0 { - t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps) - } - if readiness.TotalSteps != 6 { - t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps) - } - if readiness.CurrentOperator.CanReceiveNow { - t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB") - } - - fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: "quick_setup_all", - }, principal) - if err != nil { - t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err) - } - if !fixResp.Readiness.CanReceiveRequestsNow { - t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps) - } - if fixResp.Readiness.ReadySteps != 6 { - t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps) - } - if !fixResp.Readiness.CurrentOperator.CanReceiveNow { - t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes) - } - - // Second call must be idempotent - fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: "quick_setup_all", - }, principal) - if err != nil { - t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err) - } - if !fixResp2.Readiness.CanReceiveRequestsNow { - t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all") - } - - var teamCount int64 - db.Model(&models.AgentTeam{}).Count(&teamCount) - if teamCount != 1 { - t.Fatalf("expected idempotent 1 team, got %d", teamCount) - } -} - -func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) { - db := setupSetupReadinessTestDB(t) - - now := time.Now() - opUser := models.User{ - Username: "operator1", - Nickname: "Operator One", - Status: enums.StatusOk, - } - if err := db.Create(&opUser).Error; err != nil { - t.Fatalf("create user error = %v", err) - } - team := models.AgentTeam{ - Name: "Support Team", - Status: enums.StatusOk, - } - if err := db.Create(&team).Error; err != nil { - t.Fatalf("create team error = %v", err) - } - // Schedule in the past (off-hours now) - pastSchedule := models.AgentTeamSchedule{ - TeamID: team.ID, - StartAt: now.Add(-48 * time.Hour), - EndAt: now.Add(-24 * time.Hour), - Status: enums.StatusOk, - } - if err := db.Create(&pastSchedule).Error; err != nil { - t.Fatalf("create past schedule error = %v", err) - } - // Operator is Busy and AutoAssignEnabled = false - profile := models.AgentProfile{ - UserID: opUser.ID, - TeamID: team.ID, - AgentCode: "OP-001", - DisplayName: "Operator One", - ServiceStatus: enums.ServiceStatusBusy, - MaxConcurrentCount: 5, - AutoAssignEnabled: false, - Status: enums.StatusOk, - } - if err := db.Create(&profile).Error; err != nil { - t.Fatalf("create profile error = %v", err) - } - if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { - t.Fatalf("update auto_assign_enabled error = %v", err) - } - - principal := &dto.AuthPrincipal{ - UserID: opUser.ID, - Username: opUser.Username, - Nickname: opUser.Nickname, - Roles: []string{constants.RoleCodeAdmin}, - } - - readiness, err := services.SetupReadinessService.GetReadiness(principal) - if err != nil { - t.Fatalf("GetReadiness error = %v", err) - } - if readiness.CurrentOperator.TeamHasScheduleNow { - t.Fatalf("expected TeamHasScheduleNow = false for past schedule") - } - if readiness.CurrentOperator.CanReceiveNow { - t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours") - } - - // Fix operator profile and schedule - _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: "enable_my_operator_profile", - }, principal) - if err != nil { - t.Fatalf("enable_my_operator_profile error = %v", err) - } - readinessAfter, err := services.SetupReadinessService.GetReadiness(principal) - if err != nil { - t.Fatalf("GetReadiness after fix error = %v", err) - } - if !readinessAfter.CurrentOperator.CanReceiveNow { - t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes) - } -} - -func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) { - db := setupSetupReadinessTestDB(t) - - opUser := models.User{ - Username: "op2", - Nickname: "Operator Two", - Status: enums.StatusOk, - } - db.Create(&opUser) - principal := &dto.AuthPrincipal{ - UserID: opUser.ID, - Username: opUser.Username, - Nickname: opUser.Nickname, - Roles: []string{constants.RoleCodeAdmin}, - } - - // Setup team + active schedule + idle operator - _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: "enable_my_operator_profile", - }, principal) - if err != nil { - t.Fatalf("enable_my_operator_profile error = %v", err) - } - - // Create AI Agent in AIOnly mode and empty TeamIDs - agent := models.AIAgent{ - Name: "AI Bot", - Status: enums.StatusOk, - ServiceMode: enums.IMConversationServiceModeAIOnly, - RolloutPercent: 100, - TeamIDs: "", - } - db.Create(&agent) - - channel := models.Channel{ - Name: "Web Widget", - ChannelType: "web", - ChannelID: "web-test-1", - AIAgentID: agent.ID, - AIAgentRolloutPercent: 100, - Status: enums.StatusOk, - } - db.Create(&channel) - - sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ - ChannelID: channel.ID, - BypassAI: true, - }, principal) - if err != nil { - t.Fatalf("SimulateRoute 1 error = %v", err) - } - if sim1.WillReachOperator { - t.Fatalf("expected WillReachOperator = false when AIOnly") - } - if sim1.FinalOutcomeCode != "blocked_ai_only_mode" { - t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode) - } - - // Switch to HumanOnly but still empty TeamIDs - db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly) - sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ - ChannelID: channel.ID, - BypassAI: true, - }, principal) - if err != nil { - t.Fatalf("SimulateRoute 2 error = %v", err) - } - if sim2.WillReachOperator { - t.Fatalf("expected WillReachOperator = false when TeamIDs is empty") - } - if sim2.FinalOutcomeCode != "blocked_agent_no_teams" { - t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode) - } - - // Fix AI Agent handoff via quick fix - _, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: "fix_ai_agent_handoff", - EntityID: agent.ID, - }, principal) - if err != nil { - t.Fatalf("fix_ai_agent_handoff error = %v", err) - } - - sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ - ChannelID: channel.ID, - BypassAI: true, - }, principal) - if err != nil { - t.Fatalf("SimulateRoute 3 error = %v", err) - } - if !sim3.WillReachOperator { - t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode) - } - if sim3.FinalOutcomeCode != "assigned_to_operator" { - t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode) - } - if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID { - t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator) - } -} - -func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) { - db := setupSetupReadinessTestDB(t) - - csUser := models.User{ - Username: "cs_agent", - Nickname: "Support Agent", - Status: enums.StatusOk, - } - if err := db.Create(&csUser).Error; err != nil { - t.Fatalf("create csUser error = %v", err) - } - - // Regular cs_user only has view permissions - csPrincipal := &dto.AuthPrincipal{ - UserID: csUser.ID, - Username: csUser.Username, - Nickname: csUser.Nickname, - Roles: []string{constants.RoleCodeCsUser}, +func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal { + t.Helper() + user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk} + if err := db.Create(&user).Error; err != nil { t.Fatal(err) } + return &dto.AuthPrincipal{ + UserID: user.ID, Username: user.Username, Nickname: user.Nickname, + Roles: []string{constants.RoleCodeAdmin}, Permissions: []string{ - constants.PermissionAgentView.Code, - constants.PermissionAgentTeamView.Code, - constants.PermissionAgentTeamScheduleView.Code, - constants.PermissionAIAgentView.Code, - constants.PermissionChannelView.Code, + constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code, + constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code, + constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code, + constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code, + constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code, + constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code, }, } +} - // 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions - readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal) - if err != nil { - t.Fatalf("GetReadiness error = %v", err) +func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse { + t.Helper() + preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatalf("preview: %v", err) } + if preview.Applied || preview.PreviewToken == "" { t.Fatalf("expected read-only preview: %+v", preview) } + req.Apply, req.PreviewToken = true, preview.PreviewToken + applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatalf("apply: %v", err) } + if !applied.Applied { t.Fatal("reviewed changes were not applied") } + return applied +} + +func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) { + t.Helper() + var count int64 + if err := db.Model(model).Count(&count).Error; err != nil { t.Fatal(err) } + if count != want { t.Fatalf("%T count = %d, want %d", model, count, want) } +} + +func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} + preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatal(err) } + if preview.Applied || len(preview.Changes) != 3 { t.Fatalf("expected three proposed draft setup records: %+v", preview) } + for _, change := range preview.Changes { + if change.EntityName == "" || len(change.After) == 0 { t.Fatalf("unnamed or unspecified change: %+v", change) } } - for _, step := range readiness.Steps { - if len(step.QuickFixActions) != 0 { - t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions) - } + for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) } + req.Apply = true + if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("apply without preview must fail") } + req.PreviewToken = preview.PreviewToken + applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatal(err) } + if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow { t.Fatalf("configuration must not claim shift coverage: %+v", applied) } + for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) } + assertSetupCount(t, db, &models.Channel{}, 0) + assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) + second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) + if len(second.Changes) != 0 { t.Fatalf("existing configuration must be preserved: %+v", second.Changes) } +} + +func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} + preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatal(err) } + if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } + req.Apply, req.PreviewToken = true, preview.PreviewToken + if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("stale preview must fail") } + assertSetupCount(t, db, &models.AgentTeam{}, 1) + assertSetupCount(t, db, &models.AgentProfile{}, 0) + assertSetupCount(t, db, &models.AIAgent{}, 0) + assertSetupCount(t, db, &models.Channel{}, 0) +} + +func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} + preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatal(err) } + if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" { tx.AddError(errors.New("injected agent write failure")) } + }); err != nil { t.Fatal(err) } + req.Apply, req.PreviewToken = true, preview.PreviewToken + if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("expected injected transaction failure") } + for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} { assertSetupCount(t, db, model, 0) } +} + +func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + team := models.AgentTeam{Name: "Deleted team", Status: enums.StatusDeleted} + disabledTeam := models.AgentTeam{Name: "Disabled team", Status: enums.StatusDisabled} + profile := models.AgentProfile{UserID: principal.UserID, AgentCode: "deleted-profile", Status: enums.StatusDeleted} + deletedAgent := models.AIAgent{Name: "Deleted agent", Status: enums.StatusDeleted} + disabledAgent := models.AIAgent{Name: "Disabled agent", Status: enums.StatusDisabled, RolloutPercent: 100} + zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100} + deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted} + disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100} + for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } } + if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil { t.Fatal(err) } + if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil { t.Fatal(err) } + result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) + if len(result.Changes) != 0 { t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes) } + for _, req := range []request.SetupQuickFixRequest{ + {Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, + {Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID}, + {Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID}, + {Action: services.QuickFixActionEnableMyProfile}, + } { if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatalf("deleted target must not be restored: %+v", req) } } + for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} { + res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal) + if len(res.Changes) != 0 { t.Fatalf("disabled/zero rollout changed: %+v", res) } + } + if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil { t.Fatal(err) } + if zeroAgent.RolloutPercent != 0 { t.Fatal("zero rollout was overwritten") } + if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil { t.Fatal(err) } + if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 { t.Fatal("disabled channel was overwritten") } + assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) +} + +func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"} + second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"} + for _, agent := range []*models.AIAgent{&first, &second} { + if err := db.Create(agent).Error; err != nil { t.Fatal(err) } + if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil { t.Fatal(err) } + } + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil { t.Fatal("existing agents require a named target") } + result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal) + if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID { t.Fatalf("wrong scope: %+v", result.Changes) } + if err := db.First(&first, first.ID).Error; err != nil { t.Fatal(err) } + if err := db.First(&second, second.ID).Error; err != nil { t.Fatal(err) } + if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" { t.Fatal("selected agent was not minimally updated") } + if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" { t.Fatal("unrelated agent was changed") } +} + +func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + now := time.Now() + team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk} + other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk} + for _, model := range []any{&team, &other} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } } + schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24*time.Hour), EndAt: now.Add(32*time.Hour), Status: enums.StatusOk} + if err := db.Create(&schedule).Error; err != nil { t.Fatal(err) } + result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal) + if len(result.Changes) != 0 { t.Fatal("off-hours team must not be mutated") } + assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1) + if result.Readiness == nil { t.Fatal("missing readiness") } + foundOffHours, foundMissing := false, false + for _, step := range result.Readiness.Steps { + if step.Key != "schedules" { continue } for _, issue := range step.Issues { - if issue.QuickFixAction != "" { - t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction) - } + if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true } + if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true } + if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") } } } - - // 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched - for _, action := range []string{ - services.QuickFixActionQuickSetupAll, - services.QuickFixActionEnsureTeamSchedule, - services.QuickFixActionEnableMyProfile, - services.QuickFixActionFixAIAgentHandoff, - services.QuickFixActionEnsureDefaultChannel, - } { - _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: action, - }, csPrincipal) - if err == nil { - t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action) - } - } - - var teamCount, agentCount, channelCount, profileCount int64 - db.Model(&models.AgentTeam{}).Count(&teamCount) - db.Model(&models.AIAgent{}).Count(&agentCount) - db.Model(&models.Channel{}).Count(&channelCount) - db.Model(&models.AgentProfile{}).Count(&profileCount) - if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 { - t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d", - teamCount, agentCount, channelCount, profileCount) - } - - // 3. Seed an active team + active schedule + busy profile for csUser - now := time.Now() - team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk} - db.Create(&team) - db.Create(&models.AgentTeamSchedule{ - TeamID: team.ID, - StartAt: now.Add(-1 * time.Hour), - EndAt: now.Add(24 * time.Hour), - Status: enums.StatusOk, - }) - db.Create(&models.AgentProfile{ - UserID: csUser.ID, - TeamID: team.ID, - AgentCode: "AG-CS1", - DisplayName: "Support Agent", - ServiceStatus: enums.ServiceStatusBusy, - MaxConcurrentCount: 5, - AutoAssignEnabled: true, - Status: enums.StatusOk, - }) - - // Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle, - // but still cannot modify AI agents or channels! - statusOnlyPrincipal := &dto.AuthPrincipal{ - UserID: csUser.ID, - Username: csUser.Username, - Nickname: csUser.Nickname, - Roles: []string{constants.RoleCodeCsUser}, - Permissions: []string{ - constants.PermissionAgentView.Code, - constants.PermissionAgentUpdateStatus.Code, - }, - } - - if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: services.QuickFixActionFixAIAgentHandoff, - }, statusOnlyPrincipal); err == nil { - t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff") - } - if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: services.QuickFixActionEnsureDefaultChannel, - }, statusOnlyPrincipal); err == nil { - t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel") - } - - fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ - Action: services.QuickFixActionEnableMyProfile, - }, statusOnlyPrincipal) - if err != nil { - t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err) - } - if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow { - t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes) - } + if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") } +} + +func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + principal.Permissions = nil + for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} { + principal.Roles = []string{role} + for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} { + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) } + } + } + assertSetupCount(t, db, &models.AgentTeam{}, 0) +} + +func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk} + if err := db.Create(&team).Error; err != nil { t.Fatal(err) } + profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5} + if err := db.Create(&profile).Error; err != nil { t.Fatal(err) } + principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code} + result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal) + if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 { t.Fatalf("status-only permission exceeded: %+v", result.Changes) } + assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) + if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) } + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") } +} + +func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + for _, channelID := range []int64{0, 9999} { + result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal) + if err != nil { t.Fatal(err) } + encoded, err := json.Marshal(result) + if err != nil { t.Fatal(err) } + if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) } + } + applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) + var team models.AgentTeam + var agent models.AIAgent + if err := db.First(&team).Error; err != nil { t.Fatal(err) } + if err := db.First(&agent).Error; err != nil { t.Fatal(err) } + if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) } + channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk} + if err := db.Create(&channel).Error; err != nil { t.Fatal(err) } + now := time.Now() + if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) } + result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) + if err != nil { t.Fatal(err) } + if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) } + if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") } + if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) } + result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) + if err != nil { t.Fatal(err) } + if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" { t.Fatalf("AI route must not promise assignment: %+v", result) } + if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil { t.Fatal(err) } + result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal) + if err != nil { t.Fatal(err) } + if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) } +} + +func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll} + preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatal(err) } + if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) } + }); err != nil { t.Fatal(err) } + req.Apply, req.PreviewToken = true, preview.PreviewToken + result, err := services.SetupReadinessService.ApplyQuickFix(req, principal) + if err != nil { t.Fatalf("committed repair must not appear rolled back: %v", err) } + if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") { t.Fatalf("dispatch failure must be visible: %+v", result) } + for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) } + assertSetupCount(t, db, &models.Channel{}, 0) +} + +func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) + result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal) + if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") { t.Fatalf("draft agent must not be bound implicitly: %+v", result) } + assertSetupCount(t, db, &models.Channel{}, 0) + var agent models.AIAgent + if err := db.First(&agent).Error; err != nil { t.Fatal(err) } + // This unit fixture supplies the publication marker; revision publication itself + // belongs to the existing agent publishing service's tests. + if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) } + result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal) + if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" { t.Fatalf("expected reviewed channel create: %+v", result.Changes) } + assertSetupCount(t, db, &models.Channel{}, 1) + assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0) +} + +func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) { + db := setupSetupReadinessTestDB(t) + principal := setupRepairPrincipal(t, db) + applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal) + var profile models.AgentProfile + if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil { t.Fatal(err) } + if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil { t.Fatal("my-profile action must not silently substitute a different named target") } } diff --git a/internal/services/ws_auth_security_test.go b/internal/services/ws_auth_security_test.go new file mode 100644 index 0000000..3d20796 --- /dev/null +++ b/internal/services/ws_auth_security_test.go @@ -0,0 +1,184 @@ +package services + +import ( + "slices" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func newWsAuthFixture(t *testing.T, hasConversationView bool) *authSessionFixture { + t.Helper() + f := newAuthSessionFixture(t) + f.permission.Code = constants.PermissionConversationView.Code + if err := f.db.Model(&f.permission).Update("code", f.permission.Code).Error; err != nil { + t.Fatal(err) + } + permissions := []string{f.permission.Code} + if !hasConversationView { + if err := f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error; err != nil { + t.Fatal(err) + } + permissions = nil + } + f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, permissions) + previousDB := sqls.DB() + sqls.SetDB(f.db) + t.Cleanup(func() { sqls.SetDB(previousDB) }) + return f +} + +func newWsAuthClient(f *authSessionFixture, id, role string) *ClientSession { + return &ClientSession{ + ID: id, + Role: role, + Principal: f.principal, + Topics: make(map[string]struct{}), + Send: make(chan []byte, 16), + } +} + +func TestWsStaffWithoutViewPermissionHasNoConversationDefaults(t *testing.T) { + f := newWsAuthFixture(t, false) + ws := newWsService() + session := newWsAuthClient(f, "no-view", realtimeRoleAdmin) + if !ws.revalidateSession(session) { + t.Fatal("expected a valid authenticated staff session") + } + if topics := ws.defaultTopics(session); len(topics) != 0 { + t.Fatalf("staff without view permission received default topics: %v", topics) + } + if topics := ws.filterAllowedTopics(session, []string{realtimeTopicAdminAll, ws.adminTopic(f.user.ID), "conversation:100"}); len(topics) != 0 { + t.Fatalf("staff without view permission could subscribe: %v", topics) + } +} + +func TestWsNotificationSessionOnlyReceivesOwnNotificationTopic(t *testing.T) { + for _, hasView := range []bool{false, true} { + name := "without view permission" + if hasView { + name = "with view permission" + } + t.Run(name, func(t *testing.T) { + f := newWsAuthFixture(t, hasView) + ws := newWsService() + session := newWsAuthClient(f, "notification", realtimeRoleNotification) + ownTopic := ws.notificationTopic(f.user.ID) + if got := ws.defaultTopics(session); !slices.Equal(got, []string{ownTopic}) { + t.Fatalf("unexpected notification defaults: %v", got) + } + requested := []string{ownTopic, ws.notificationTopic(f.user.ID + 1), realtimeTopicAdminAll, "conversation:100"} + if got := ws.filterAllowedTopics(session, requested); !slices.Equal(got, []string{ownTopic}) { + t.Fatalf("notification connection escaped its own topic: %v", got) + } + // Simulate stale or incorrectly registered membership. Delivery must + // still reject conversation events, including when view is granted. + ws.manager.Register(session, requested) + ws.PublishToTopic("conversation:100", ws.newEvent("conversation:100", RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated})) + ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated})) + if len(session.Send) != 0 { + t.Fatal("notification connection received a conversation event") + } + ws.PublishToTopic(ownTopic, ws.newEvent(ownTopic, RealtimeNotificationCreatedEvent{})) + if len(session.Send) != 1 || session.Closed.Load() { + t.Fatal("valid notification connection did not receive its own notification") + } + }) + } +} + +func TestWsStaleAdminAllMembershipDoesNotGrantDelivery(t *testing.T) { + f := newWsAuthFixture(t, false) + ws := newWsService() + session := newWsAuthClient(f, "stale-member", realtimeRoleAdmin) + ws.manager.Register(session, []string{realtimeTopicAdminAll}) + ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated})) + if len(session.Send) != 0 { + t.Fatal("stale admin:all membership bypassed conversation-view permission") + } +} + +func TestWsStaffRevocationClosesBeforeDeliveryOrIncomingMessage(t *testing.T) { + cases := []struct { + name string + change func(*authSessionFixture) error + }{ + {"revoked login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", time.Now()).Error }}, + {"expired login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", time.Now().Add(-time.Second)).Error }}, + {"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }}, + {"downgraded user type", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("user_type", enums.UserTypeUser).Error }}, + {"deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", time.Now()).Error }}, + {"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }}, + {"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }}, + {"removed permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }}, + {"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }}, + } + for _, tc := range cases { + for _, trigger := range []string{"fanout", "incoming"} { + t.Run(tc.name+"/"+trigger, func(t *testing.T) { + f := newWsAuthFixture(t, true) + ws := newWsService() + session := newWsAuthClient(f, "staff", realtimeRoleAdmin) + ws.manager.Register(session, ws.defaultTopics(session)) + if !ws.revalidateSession(session) { + t.Fatal("expected valid staff session before access change") + } + if err := tc.change(f); err != nil { + t.Fatal(err) + } + event := ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}) + if trigger == "fanout" { + ws.PublishToTopic(realtimeTopicAdminAll, event) + } else { + ws.handleClientMessage(session, []byte(`{"type":"ping"}`)) + } + if !session.Closed.Load() || ws.manager.HasTopic(realtimeTopicAdminAll) { + t.Fatal("stale staff connection remained registered") + } + if len(session.Send) != 0 { + t.Fatal("stale staff connection received an event or pong") + } + ws.handleClientMessage(session, []byte(`{"type":"subscribe","topics":["admin:all","conversation:100"]}`)) + ws.PublishToTopic(realtimeTopicAdminAll, event) + if len(session.Send) != 0 || len(ws.manager.TopicList(session)) != 0 { + t.Fatal("closed connection processed later messages or delivery") + } + }) + } + } +} + +// Existing realtime integration tests also need real login-session bindings now +// that delivery revalidates authorization instead of trusting topic membership. +func seedRealtimeStaffPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal { + t.Helper() + if err := db.AutoMigrate(&models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}, &models.LoginSession{}); err != nil { + t.Fatal(err) + } + user := models.User{ID: 1001, Username: "realtime-observer", UserType: enums.UserTypeEmployee, Status: enums.StatusOk} + role := models.Role{Code: "realtime-observer", Status: enums.StatusOk} + permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk} + for _, item := range []any{&user, &role, &permission} { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + session := models.LoginSession{UserID: user.ID, Token: "ak_test_only_realtime", ExpiredAt: time.Now().Add(time.Hour)} + for _, item := range []any{ + &session, + &models.UserRole{UserID: user.ID, RoleID: role.ID}, + &models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}, + } { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + return buildAuthPrincipal(&user, &session, []string{role.Code}, []string{permission.Code}) +} diff --git a/internal/services/ws_connection_manager.go b/internal/services/ws_connection_manager.go index e2e3c70..9eb3669 100644 --- a/internal/services/ws_connection_manager.go +++ b/internal/services/ws_connection_manager.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import "sync" @@ -23,6 +25,7 @@ func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []s m.mu.Lock() defer m.mu.Unlock() + if session.Closed.Load() { return len(m.sessions) } m.sessions[session.ID] = session for _, topic := range defaultTopics { m.subscribeLocked(session, topic) @@ -53,6 +56,7 @@ func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string) m.mu.Lock() defer m.mu.Unlock() + if session.Closed.Load() { return nil } ret := make([]string, 0, len(topics)) for _, topic := range topics { if _, exists := session.Topics[topic]; exists { @@ -132,3 +136,12 @@ func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic st } delete(session.Topics, topic) } + +func (m *WsConnectionManager) TopicList(session *ClientSession) []string { + m.mu.RLock() + defer m.mu.RUnlock() + if session == nil { return nil } + topics := make([]string, 0, len(session.Topics)) + for topic := range session.Topics { topics = append(topics, topic) } + return topics +} diff --git a/internal/services/ws_pump_security_test.go b/internal/services/ws_pump_security_test.go new file mode 100644 index 0000000..d14f36d --- /dev/null +++ b/internal/services/ws_pump_security_test.go @@ -0,0 +1,104 @@ +package services + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-desk/internal/pkg/enums" + "github.com/gorilla/websocket" +) + +// The listener exists only inside the test. No app server or external service is +// involved; the DB fixture is temporary and every pump is joined before cleanup. +func newSecuritySocketPair(t *testing.T) (*websocket.Conn,*websocket.Conn) { + t.Helper() + accepted:=make(chan *websocket.Conn,1) + failed:=make(chan error,1) + server:=httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){ + upgrader:=websocket.Upgrader{} + conn,err:=upgrader.Upgrade(w,r,nil) + if err!=nil{failed<-err;return} + accepted<-conn + })) + t.Cleanup(server.Close) + client,_,err:=websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL,"http"),nil) + if err!=nil{t.Fatal(err)} + var serverConn *websocket.Conn + select{ + case serverConn=<-accepted: + case err:=<-failed:t.Fatal(err) + case <-time.After(time.Second):t.Fatal("test websocket upgrade timed out") + } + t.Cleanup(func(){_ = client.Close();_ = serverConn.Close()}) + return serverConn,client +} + +func startSecurityPumps(t *testing.T,ws *wsService,session *ClientSession,reader bool) { + t.Helper() + written:=make(chan struct{}) + go func(){defer close(written);ws.writePump(session)}() + var read chan struct{} + if reader{ + read=make(chan struct{}) + go func(){defer close(read);ws.readPump(session)}() + } + t.Cleanup(func(){ + ws.closeSession(session) + select{case <-written:case <-time.After(time.Second):t.Error("write pump did not stop")} + if read!=nil{select{case <-read:case <-time.After(time.Second):t.Error("read pump did not stop")}} + }) +} + +func requireSecuritySocketClosed(t *testing.T,client *websocket.Conn) { + t.Helper() + _ = client.SetReadDeadline(time.Now().Add(time.Second)) + _,_,err:=client.ReadMessage() + if err==nil{t.Fatal("revoked connection delivered a buffered message")} + if networkErr,ok:=err.(interface{Timeout()bool});ok && networkErr.Timeout(){t.Fatal("revoked connection stayed open until test deadline")} +} + +func TestWsWritePumpRejectsBufferedEventAfterRevocation(t *testing.T) { + f:=newWsAuthFixture(t,true) + ws:=newWsService() + session:=newWsAuthClient(f,"buffered",realtimeRoleAdmin) + serverConn,client:=newSecuritySocketPair(t) + session.Conn=serverConn + ws.manager.Register(session,ws.defaultTopics(session)) + session.enqueueEvent(ws.newEvent(realtimeTopicAdminAll,RealtimeConversationChangedEvent{Type:enums.IMRealtimeEventConversationUpdated})) + if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)} + startSecurityPumps(t,ws,session,false) + requireSecuritySocketClosed(t,client) + if !session.Closed.Load(){t.Fatal("writer did not revoke session")} +} + +func TestWsReadPumpRejectsIncomingFrameAfterRevocation(t *testing.T) { + f:=newWsAuthFixture(t,true) + ws:=newWsService() + session:=newWsAuthClient(f,"incoming",realtimeRoleAdmin) + serverConn,client:=newSecuritySocketPair(t) + session.Conn=serverConn + ws.manager.Register(session,ws.defaultTopics(session)) + startSecurityPumps(t,ws,session,true) + if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)} + if err:=client.WriteMessage(websocket.TextMessage,[]byte(`{"type":"ping"}`));err!=nil{t.Fatal(err)} + requireSecuritySocketClosed(t,client) + if !session.Closed.Load(){t.Fatal("reader did not revoke session")} +} + +func TestWsIdlePumpClosesRevokedConnection(t *testing.T) { + f:=newWsAuthFixture(t,true) + ws:=newWsService() + // Same production timer path, shortened only on this isolated service instance. + ws.authCheckPeriod=10*time.Millisecond + session:=newWsAuthClient(f,"idle",realtimeRoleAdmin) + serverConn,client:=newSecuritySocketPair(t) + session.Conn=serverConn + ws.manager.Register(session,ws.defaultTopics(session)) + startSecurityPumps(t,ws,session,false) + if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)} + requireSecuritySocketClosed(t,client) + if !session.Closed.Load(){t.Fatal("idle authorization timer did not revoke session")} +} diff --git a/internal/services/ws_realtime_types.go b/internal/services/ws_realtime_types.go index 66c0359..1fabc4f 100644 --- a/internal/services/ws_realtime_types.go +++ b/internal/services/ws_realtime_types.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -17,6 +19,7 @@ const ( realtimeWriteWait = 10 * time.Second realtimePongWait = 60 * time.Second realtimePingPeriod = 30 * time.Second + realtimeAuthCheckPeriod = 5 * time.Second realtimeMaxMessageSize = 8 << 10 realtimeSendBufferSize = 64 ) @@ -28,8 +31,7 @@ const ( ) const ( - realtimeTopicUserPrefix = "user:" - realtimeTopicGuestPrefix = "guest:" + realtimeTopicCustomerPrefix = "customer:" realtimeTopicAdminPrefix = "admin:" realtimeTopicConversationPrefix = "conversation:" realtimeTopicNotificationPrefix = "notification:" @@ -315,6 +317,8 @@ type ClientSession struct { Conn *websocket.Conn Principal *dto.AuthPrincipal External *openidentity.ExternalUser + CustomerSession *CustomerSessionVerifyResult + sendMu sync.RWMutex Role string TerminalType string Topics map[string]struct{} @@ -325,9 +329,10 @@ type ClientSession struct { } func (s *ClientSession) enqueue(payload []byte) bool { - if s == nil || s.Closed.Load() { - return false - } + if s == nil { return false } + s.sendMu.RLock() + defer s.sendMu.RUnlock() + if s.Closed.Load() { return false } select { case s.Send <- payload: return true @@ -350,14 +355,3 @@ func (s *ClientSession) touch() { } s.LastActiveAt.Store(time.Now().Unix()) } - -func (s *ClientSession) topicList() []string { - if s == nil { - return nil - } - ret := make([]string, 0, len(s.Topics)) - for topic := range s.Topics { - ret = append(ret, topic) - } - return ret -} diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index f367553..c93307e 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -31,6 +33,7 @@ type wsService struct { upgrader websocket.Upgrader seq atomic.Uint64 manager *WsConnectionManager + authCheckPeriod time.Duration } func newWsService() *wsService { @@ -41,6 +44,7 @@ func newWsService() *wsService { }, }, manager: newWsConnectionManager(), + authCheckPeriod: realtimeAuthCheckPeriod, } } @@ -50,6 +54,10 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired"))) return } + if !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) { + ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonErrorCode(errorsx.CodeAuthForbidden, i18nx.T(ctx, "error.e0225"))) + return + } if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil { slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path) ctx.Abort() @@ -77,29 +85,35 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) { return } - var ( - principal = AuthService.GetAuthPrincipal(ctx) - external *openidentity.ExternalUser - customerSessionInfo *CustomerSessionVerifyResult - ) - if principal == nil { - result, err := CustomerSessionService.VerifyRequest(ctx, channel) - if err != nil { - ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err)) - return - } - external = result.ExternalUser - customerSessionInfo = result - } - if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil { - slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID) - ctx.Abort() - return - } + // The customer endpoint always authenticates the customer credential. A staff + // cookie/bearer must not turn this into a second dashboard subscription path. + result, err := CustomerSessionService.VerifyRequest(ctx, channel) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err)) + return + } + if err := s.upgradeConnection(ctx, nil, result.ExternalUser, realtimeRoleUser, result); err != nil { + slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channel_id", channel.ID) + ctx.Abort() + return + } } func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error { - conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if principal != nil { + fresh, err := AuthService.RevalidatePrincipal(principal) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err)) + return err + } + principal = fresh + if role == realtimeRoleAdmin && !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) { + err := errorsx.ForbiddenI18n("error.e0225") + ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonError(err)) + return err + } + } + conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil) if err != nil { return err } @@ -114,9 +128,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc Topics: make(map[string]struct{}), Send: make(chan []byte, realtimeSendBufferSize), } - session.touch() + if len(customerSessionInfo) > 0 { session.CustomerSession = customerSessionInfo[0] } + session.touch() - conn.SetReadLimit(realtimeMaxMessageSize) + conn.SetReadLimit(realtimeMaxMessageSize) _ = conn.SetReadDeadline(time.Now().Add(realtimePongWait)) conn.SetPongHandler(func(string) error { session.touch() @@ -139,13 +154,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc "userId", logUserID, "externalId", logExternalID, "terminalType", session.TerminalType, - "topicCount", len(session.Topics), + "topicCount", len(s.manager.TopicList(session)), "sessionCount", sessionCount, ) - go s.writePump(session) - go s.readPump(session) - session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{ Payload: RealtimeConnectedPayload{ ConnID: session.ID, @@ -153,7 +165,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc GuestID: logExternalID, Role: role, TerminalType: session.TerminalType, - Topics: session.topicList(), + Topics: s.manager.TopicList(session), }, })) if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed { @@ -164,6 +176,8 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc }, })) } + go s.writePump(session) + go s.readPump(session) return nil } @@ -181,6 +195,7 @@ func (s *wsService) readPump(session *ClientSession) { } func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) { + if !s.revalidateSession(session) { return } input := realtimeClientMessage{} if err := json.Unmarshal(payload, &input); err != nil { session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{ @@ -214,7 +229,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) "eventId", strings.TrimSpace(input.EventID), ) case enums.IMRealtimeClientTypePresence: - if input.ConversationID > 0 && session.Principal != nil { + if input.ConversationID > 0 && session.Role == realtimeRoleAdmin && session.Principal != nil && s.canSubscribeConversation(session, input.ConversationID) { action := strings.TrimSpace(input.Action) if action == "" { action = "viewing" @@ -232,8 +247,10 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) func (s *wsService) writePump(session *ClientSession) { ticker := time.NewTicker(realtimePingPeriod) + authTicker := time.NewTicker(s.authCheckPeriod) defer func() { ticker.Stop() + authTicker.Stop() s.closeSession(session) }() @@ -245,9 +262,16 @@ func (s *wsService) writePump(session *ClientSession) { _ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{}) return } - if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil { + // Recheck after queueing as well: logout or permission revocation can + // happen while a slow client still has messages buffered. + if !s.revalidateSession(session) { return } + var envelope struct { Topic string `json:"topic"` } + if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) { continue } + if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil { return } + case <-authTicker.C: + if !s.revalidateSession(session) { return } case <-ticker.C: _ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait)) if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { @@ -262,10 +286,12 @@ func (s *wsService) closeSession(session *ClientSession) { return } session.closeOnce.Do(func() { + session.sendMu.Lock() session.Closed.Store(true) + if session.Send != nil { close(session.Send) } + session.sendMu.Unlock() remaining := s.manager.Unregister(session) - close(session.Send) if session.Conn != nil { _ = session.Conn.Close() } @@ -586,8 +612,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve return } - for _, session := range targets { - if filter != nil && !filter(session) { + for _, session := range targets { + if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) { continue } + if filter != nil && !filter(session) { continue } if session.enqueue(payload) { @@ -603,12 +630,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve } } -func (s *wsService) IsGuestOnline(guestID string) bool { - guestID = strings.TrimSpace(guestID) - if guestID == "" { - return false - } - return s.manager.HasTopic(s.guestTopic(guestID)) +func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool { + if customerID <= 0 || channelID <= 0 { return false } + return s.manager.HasTopic(s.customerTopic(customerID, channelID)) } func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string { @@ -617,8 +641,8 @@ func (s *wsService) routeConversationTopics(conversation *models.Conversation) [ } topics := []string{s.conversationTopic(conversation.ID)} - if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" { - topics = append(topics, s.guestTopic(identity.ExternalID)) + if conversation.CustomerID > 0 && conversation.ChannelID > 0 { + topics = append(topics, s.customerTopic(conversation.CustomerID, conversation.ChannelID)) } if conversation.CurrentAssigneeID > 0 { topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID)) @@ -640,19 +664,15 @@ func (s *wsService) defaultTopics(session *ClientSession) []string { } return []string{s.notificationTopic(session.Principal.UserID)} case realtimeRoleAdmin: - if session.Principal == nil || session.Principal.UserID <= 0 { - return []string{realtimeTopicAdminAll} + if session.Principal == nil || session.Principal.UserID <= 0 || !slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) { + return nil } return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll} - default: - // 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。 - if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" { - return []string{s.guestTopic(session.External.ExternalID)} - } - if session.Principal != nil && session.Principal.UserID > 0 { - return []string{s.userTopic(session.Principal.UserID)} - } - return nil + default: + if session.CustomerSession != nil && session.External != nil && session.CustomerSession.CustomerID > 0 && session.CustomerSession.ChannelID > 0 { + return []string{s.customerTopic(session.CustomerSession.CustomerID, session.CustomerSession.ChannelID)} + } + return nil } } @@ -693,7 +713,7 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string) } func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool { - if session == nil || conversationID <= 0 { + if session == nil || conversationID <= 0 || session.Role == realtimeRoleNotification { return false } if session.Role == realtimeRoleAdmin { @@ -751,12 +771,8 @@ func (s *wsService) nextID(prefix string) string { return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq) } -func (s *wsService) userTopic(userID int64) string { - return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10) -} - -func (s *wsService) guestTopic(guestID string) string { - return realtimeTopicGuestPrefix + strings.TrimSpace(guestID) +func (s *wsService) customerTopic(customerID, channelID int64) string { + return realtimeTopicCustomerPrefix + strconv.FormatInt(channelID, 10) + ":" + strconv.FormatInt(customerID, 10) } func (s *wsService) adminTopic(userID int64) string { @@ -817,3 +833,24 @@ func formatWsTime(t *time.Time) string { } return t.Format(time.DateTime) } + +// Topic membership is an optimization, never an authorization grant. +func (s *wsService) canReceiveEvent(session *ClientSession, routingTopics []string, eventTopic string) bool { + if eventTopic != "" { return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0 } + return len(s.filterAllowedTopics(session, routingTopics)) > 0 +} + +// Principal snapshots are immutable. Closing when their scope changes avoids +// races between fanout/read/write goroutines and forces a fresh subscription set. +func (s *wsService) revalidateSession(session *ClientSession) bool { + if session == nil || session.Closed.Load() { return false } + valid := false + if session.Principal != nil { + fresh, err := AuthService.RevalidatePrincipal(session.Principal) + valid = err == nil && fresh.UserType == session.Principal.UserType && slices.Equal(fresh.Roles, session.Principal.Roles) && slices.Equal(fresh.Permissions, session.Principal.Permissions) + } else if session.Role == realtimeRoleUser { + valid = CustomerSessionService.VerifyConnection(session.CustomerSession) + } + if !valid { s.closeSession(session) } + return valid +} diff --git a/internal/services/ws_service_test.go b/internal/services/ws_service_test.go index 1f57b1c..157fa68 100644 --- a/internal/services/ws_service_test.go +++ b/internal/services/ws_service_test.go @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening. + package services import ( @@ -6,7 +8,6 @@ import ( "time" "agent-desk/internal/models" - "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" ) @@ -49,12 +50,9 @@ func TestWsAgentActivityEventType(t *testing.T) { } func TestPublishAgentActivity(t *testing.T) { + f := newWsAuthFixture(t, true) ws := newWsService() - session := &ClientSession{ - ID: "test-sess", - Topics: map[string]struct{}{}, - Send: make(chan []byte, 10), - } + session := newWsAuthClient(f, "test-sess", realtimeRoleAdmin) ws.manager.Register(session, []string{"conversation:100"}) conv := &models.Conversation{ID: 100} @@ -100,27 +98,16 @@ func TestWsOperatorPresenceEventType(t *testing.T) { } func TestPublishOperatorPresence(t *testing.T) { + f := newWsAuthFixture(t, true) ws := newWsService() - subscriber := &ClientSession{ - ID: "subscriber-conn", - Topics: map[string]struct{}{}, - Send: make(chan []byte, 10), - } + subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin) ws.manager.Register(subscriber, []string{"conversation:100"}) - sender := &ClientSession{ - ID: "sender-conn", - Topics: map[string]struct{}{}, - Send: make(chan []byte, 10), - } + sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin) ws.manager.Register(sender, []string{"conversation:100"}) - principal := &dto.AuthPrincipal{ - UserID: 2, - Username: "operator", - Nickname: "Agent Smith", - } + principal := f.principal ws.PublishOperatorPresence(100, principal, "typing", sender.ID) @@ -136,7 +123,7 @@ func TestPublishOperatorPresence(t *testing.T) { if event.Type != enums.IMRealtimeEventOperatorPresence { t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) } - if event.Data.ConversationID != 100 || event.Data.UserID != 2 || event.Data.Username != "operator" || event.Data.Nickname != "Agent Smith" || event.Data.Action != "typing" { + if event.Data.ConversationID != 100 || event.Data.UserID != principal.UserID || event.Data.Username != principal.Username || event.Data.Nickname != principal.Nickname || event.Data.Action != "typing" { t.Fatalf("unexpected data: %+v", event.Data) } case <-time.After(time.Second): @@ -151,26 +138,13 @@ func TestPublishOperatorPresence(t *testing.T) { } func TestHandleClientMessagePresence(t *testing.T) { + f := newWsAuthFixture(t, true) ws := newWsService() - subscriber := &ClientSession{ - ID: "subscriber-conn", - Topics: map[string]struct{}{}, - Send: make(chan []byte, 10), - } + subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin) ws.manager.Register(subscriber, []string{"conversation:100"}) - principal := &dto.AuthPrincipal{ - UserID: 2, - Username: "operator", - Nickname: "Agent Smith", - } - sender := &ClientSession{ - ID: "sender-conn", - Principal: principal, - Topics: map[string]struct{}{}, - Send: make(chan []byte, 10), - } + sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin) ws.manager.Register(sender, []string{"conversation:100"}) msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`) @@ -188,7 +162,7 @@ func TestHandleClientMessagePresence(t *testing.T) { if event.Type != enums.IMRealtimeEventOperatorPresence { t.Fatalf("expected %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) } - if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != 2 { + if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != f.principal.UserID { t.Fatalf("unexpected data: %+v", event.Data) } case <-time.After(time.Second): diff --git a/internal/services/ws_session_concurrency_test.go b/internal/services/ws_session_concurrency_test.go new file mode 100644 index 0000000..d70502e --- /dev/null +++ b/internal/services/ws_session_concurrency_test.go @@ -0,0 +1,30 @@ +package services + +import ( + "sync" + "testing" +) + +// Run with -race: close may be triggered independently by revocation, a reader, +// a writer, or slow-client eviction while broadcasts still hold a session pointer. +func TestWsConcurrentEnqueueCloseAndSubscribe(t *testing.T) { + ws:=newWsService() + session:=&ClientSession{ID:"concurrent",Topics:map[string]struct{}{},Send:make(chan []byte,64)} + ws.manager.Register(session,[]string{"notification:1"}) + var work sync.WaitGroup + for i:=0;i<8;i++ { + work.Add(1) + go func() { + defer work.Done() + for j:=0;j<100;j++ { + session.enqueue([]byte("test")) + ws.manager.Subscribe(session,[]string{"notification:1"}) + ws.manager.TopicList(session) + } + }() + } + ws.closeSession(session) + work.Wait() + if !session.Closed.Load() || ws.manager.HasTopic("notification:1") || len(ws.manager.TopicList(session))!=0 { t.Fatal("closed session was re-registered") } + if session.enqueue([]byte("test")) { t.Fatal("closed session accepted a message") } +} diff --git a/scripts/audit-customer-identity-scopes.sql b/scripts/audit-customer-identity-scopes.sql new file mode 100644 index 0000000..6fa9b34 --- /dev/null +++ b/scripts/audit-customer-identity-scopes.sql @@ -0,0 +1,47 @@ +-- READ-ONLY preflight after the additive identity_scope column is present. +-- Run against an operator-approved backup/staging copy, not an implicit production +-- connection. Compatible with SQLite and MySQL. This file never changes data. +-- Results contain customer identifiers; keep them private and do not commit them. + +-- 1. Any result is an ambiguous key. Runtime lookup fails closed for these keys, +-- including disabled mappings. Resolve ownership before adding a unique index. +SELECT identity_scope, external_source, external_id, + COUNT(*) AS mapping_count, COUNT(DISTINCT customer_id) AS owner_count +FROM t_customer_identity +GROUP BY identity_scope, external_source, external_id +HAVING COUNT(*) > 1; + +-- 2. Inventory legacy mappings and their conversation channels. A single channel +-- is evidence to investigate, NOT proof of the issuer or ownership. Guest IDs +-- from unauthenticated exchange are never sufficient proof to recover history. +SELECT ci.id AS identity_id, ci.customer_id, ci.external_source, ci.external_id, + ci.status AS identity_status, c.channel_id AS channel_record_id, + ch.channel_id AS channel_code, COUNT(c.id) AS conversation_count +FROM t_customer_identity AS ci +LEFT JOIN t_conversation AS c ON c.customer_id = ci.customer_id +LEFT JOIN t_channel AS ch ON ch.id = c.channel_id +WHERE ci.identity_scope = '' +GROUP BY ci.id, ci.customer_id, ci.external_source, ci.external_id, ci.status, + c.channel_id, ch.channel_id +ORDER BY ci.id, c.channel_id; + +-- 3. Mixed legacy/scoped mappings require explicit review of the legacy owner +-- and any new post-upgrade customer before reconciliation. Equal external_id +-- values across channels/issuers are not evidence of equal people. +SELECT legacy.id AS legacy_identity_id, legacy.customer_id AS legacy_customer_id, + scoped.id AS scoped_identity_id, scoped.customer_id AS scoped_customer_id, + scoped.identity_scope, legacy.external_source, legacy.external_id +FROM t_customer_identity AS legacy +JOIN t_customer_identity AS scoped + ON scoped.external_source = legacy.external_source + AND scoped.external_id = legacy.external_id +WHERE legacy.identity_scope = '' AND scoped.identity_scope <> '' +ORDER BY legacy.id, scoped.id; + +-- 4. Scoped mappings without their unique digest require a reviewed key backfill. +-- Application-created scoped mappings always have a key. Never populate it by +-- hashing only external_id: include the scope and external source as implemented +-- by repositories.ScopedCustomerIdentityKey (JSON string array, SHA-256). +SELECT id, customer_id, identity_scope, external_source, external_id +FROM t_customer_identity +WHERE identity_scope <> '' AND scoped_key IS NULL; diff --git a/web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx b/web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx index 0d485db..91bbcea 100644 --- a/web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx +++ b/web/app/(dashboard)/dashboard/_components/setup-readiness-banner.tsx @@ -1,37 +1,31 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. "use client" import { AlertTriangleIcon, ArrowRightIcon, CheckCircle2Icon, - Loader2Icon, SparklesIcon, } from "lucide-react" import Link from "next/link" import { useCallback, useEffect, useState } from "react" -import { toast } from "sonner" import { - notifySetupReadinessUpdated, SETUP_READINESS_UPDATED_EVENT, } from "@/components/operator-readiness-indicator" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { useI18n } from "@/i18n/provider" import { - applySetupQuickFix, fetchSetupReadiness, type SetupReadinessResponse, } from "@/lib/api/setup-readiness" -export function SetupReadinessBanner({ - onUpdated, -}: { +export function SetupReadinessBanner({ onUpdated }: { onUpdated?: () => void }) { const t = useI18n() const [readiness, setReadiness] = useState(null) - const [fixing, setFixing] = useState(false) const loadReadiness = useCallback(async () => { try { @@ -46,29 +40,13 @@ export function SetupReadinessBanner({ void loadReadiness() const handleUpdated = () => { void loadReadiness() + onUpdated?.() } window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) return () => { window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) } - }, [loadReadiness]) - - async function handleQuickSetupAll() { - setFixing(true) - try { - const res = await applySetupQuickFix({ action: "quick_setup_all" }) - setReadiness(res.readiness) - notifySetupReadinessUpdated() - onUpdated?.() - toast.success(t("setupWizard.quickFixSuccess")) - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("common.operationFailed"), - ) - } finally { - setFixing(false) - } - } + }, [loadReadiness, onUpdated]) if (!readiness) { return null @@ -137,24 +115,12 @@ export function SetupReadinessBanner({
{canQuickSetupAll ? ( - ) : null} + + ) : null}
))} @@ -542,6 +592,7 @@ export function SetupWizardView() { options={channelOptions} value={selectedChannelId} onChange={(val) => { + selectedChannelRef.current = val setSelectedChannelId(val) void runSimulation(Number(val)) }} @@ -744,6 +795,39 @@ export function SetupWizardView() { ) : null} + { if (!open && !fixingRef.current) setPendingFix(null) }}> + + + {t("setupWizard.preview.title")} + {t("setupWizard.preview.description")} + +
+ {pendingFix?.preview.changes.length === 0 ?

{t("setupWizard.preview.noChanges")}

: null} + {pendingFix?.preview.changes.map((change, index) => ( +
+

{t(`setupWizard.preview.${change.operation}`)}: {change.entity_name}{change.entity_id > 0 ? ` (#${change.entity_id})` : ""}

+
+ {Object.entries(change.after).map(([field, value]) => ( +
+
{t(`setupWizard.repairFields.${field}`)}
+
{change.before && field in change.before ? `${String(change.before[field])} → ` : ""}{String(value)}
+
+ ))} +
+
+ ))} + {pendingFix?.preview.warning_codes.map((code) =>

{t(`setupWizard.repairWarnings.${code}`)}

)} + {t("setupWizard.preview.manageSchedules")} +
+ + + + +
+
) } diff --git a/web/app/(support)/support/chat/closed/page.tsx b/web/app/(support)/support/chat/closed/page.tsx index da1a0ab..2aafe07 100644 --- a/web/app/(support)/support/chat/closed/page.tsx +++ b/web/app/(support)/support/chat/closed/page.tsx @@ -1,8 +1,11 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. "use client" -import { CheckCircle2Icon } from "lucide-react" +import { MessageSquareIcon } from "lucide-react" import { useI18n } from "@/i18n/provider" +import { Button } from "@/components/ui/button" +import { getStandaloneChatUrl } from "@/components/support-chat/close-navigation" export default function Page() { const t = useI18n() @@ -10,15 +13,18 @@ export default function Page() { return (
-
- +
+
-

{t("supportChat.closedTitle")}

+

{t("supportChat.dismissedTitle")}

- {t("supportChat.closedDescription")} + {t("supportChat.dismissedDescription")}

+
) diff --git a/web/components/auth-provider.tsx b/web/components/auth-provider.tsx index fec11dc..8b26333 100644 --- a/web/components/auth-provider.tsx +++ b/web/components/auth-provider.tsx @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. +// Modified for AgentDeskFixes: prevent a late logout from redirecting a newer login. "use client" import { @@ -11,7 +13,7 @@ import { import { usePathname, useRouter } from "next/navigation" import { SessionProvider, useSession } from "@/components/session-provider" -import { type AuthSession } from "@/lib/auth" +import { readSession, type AuthSession } from "@/lib/auth" type AuthContextValue = { session: AuthSession | null @@ -46,17 +48,23 @@ function DashboardAuthProvider({ children }: { children: ReactNode }) { try { await endSession() } finally { - startTransition(() => { - router.replace("/dashboard/login") - }) + // A different tab or login flow may have established a new session meanwhile. + if (!readSession()) { + startTransition(() => { + router.replace("/dashboard/login") + }) + } } } useEffect(() => { if (ready && !session && requiresAuth) { - startTransition(() => { - router.replace("/dashboard/login") - }) + // A different tab or login flow may have established a new session meanwhile. + if (!readSession()) { + startTransition(() => { + router.replace("/dashboard/login") + }) + } } }, [ready, requiresAuth, router, session]) diff --git a/web/components/chat/shared-message-editor.tsx b/web/components/chat/shared-message-editor.tsx index 46e4067..b445076 100644 --- a/web/components/chat/shared-message-editor.tsx +++ b/web/components/chat/shared-message-editor.tsx @@ -1,6 +1,8 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. +// Modified for AgentDeskFixes: preserve scoped drafts and guard asynchronous editor operations. "use client" -import { useEffect, useRef, useState, type ChangeEvent } from "react" +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ChangeEvent } from "react" import Placeholder from "@tiptap/extension-placeholder" import { EditorContent, useEditor } from "@tiptap/react" import StarterKit from "@tiptap/starter-kit" @@ -32,10 +34,14 @@ import { MessageImageExtension, removeEditorImageByTitle, revokeEditorObjectUrl, - revokeEditorObjectUrls, setEditorImageUploadingByTitle, type UploadedEditorImage, } from "@/lib/im-editor-image" +import { + createMessageEditorDraftStore, + type MessageEditorDraft, + type MessageEditorDraftStore, +} from "@/lib/message-editor-drafts" import { generateUUID } from "@/lib/utils" import { useI18n } from "@/i18n/provider" @@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = { export type SharedMessageEditorProps = { variant: SharedMessageEditorVariant conversationId?: number | null + draftScope?: string + draftStore?: MessageEditorDraftStore + sending?: boolean disabled?: boolean uploadingAsset?: boolean manageLocalUploading?: boolean @@ -79,6 +88,9 @@ export type SharedMessageEditorProps = { export function SharedMessageEditor({ variant, conversationId, + draftScope = "default", + draftStore, + sending = false, disabled = false, uploadingAsset = false, manageLocalUploading = false, @@ -91,6 +103,18 @@ export function SharedMessageEditor({ }: SharedMessageEditorProps) { const t = useI18n() const [localUploading, setLocalUploading] = useState(false) + const [localDraftStore] = useState(createMessageEditorDraftStore) + const drafts = draftStore ?? localDraftStore + const draftSending = useSyncExternalStore(drafts.subscribeSending, drafts.isSending, () => false) + const draftKey = String(conversationId ?? 0) + const displayedDraftRef = useRef<{ key: string; draft: MessageEditorDraft } | null>(null) + const contextRef = useRef({ draftKey, draftScope }) + contextRef.current = { draftKey, draftScope } + const mountedRef = useRef(false) + const suggestionRequestRef = useRef(0) + const suggestionPendingRef = useRef(false) + const uploadingRef = useRef(false) + const mutationGenerationRef = useRef(0) const [quickReplySearch, setQuickReplySearch] = useState("") const [suggestedReply, setSuggestedReply] = useState(null) const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false) @@ -105,11 +129,10 @@ export function SharedMessageEditor({ const onSendAttachmentRef = useRef(onSendAttachment) const onTypingRef = useRef(onTyping) const shouldRestoreFocusRef = useRef(false) - const objectUrlsRef = useRef>(new Set()) - const uploadedImagesRef = useRef(new Map()) const placeholderRef = useRef(t("conversation.editorPlaceholder")) const isCustomer = variant === "customer" const isUploading = uploadingAsset || (manageLocalUploading && localUploading) + const isSending = sending || draftSending placeholderRef.current = t("conversation.editorPlaceholder") @@ -118,31 +141,21 @@ export function SharedMessageEditor({ }, [quickReplies]) useEffect(() => { - const objectUrls = objectUrlsRef.current + mountedRef.current = true return () => { - revokeEditorObjectUrls(objectUrls) + mountedRef.current = false + suggestionRequestRef.current += 1 + // A supplied store belongs to the parent and survives editor unmounts. + if (!draftStore) localDraftStore.clear() } - }, []) + }, [draftStore, localDraftStore]) - useEffect(() => { + useLayoutEffect(() => { onSendRef.current = onSend - }, [onSend]) - - useEffect(() => { onUploadImageRef.current = onUploadImage - }, [onUploadImage]) - - useEffect(() => { onSendAttachmentRef.current = onSendAttachment - }, [onSendAttachment]) - - useEffect(() => { onTypingRef.current = onTyping - }, [onTyping]) - - useEffect(() => { - setSuggestedReply(null) - }, [conversationId]) + }, [onSend, onUploadImage, onSendAttachment, onTyping]) const editor = useEditor({ immediatelyRender: false, @@ -162,6 +175,8 @@ export function SharedMessageEditor({ ], content: "", onUpdate: ({ editor }) => { + const displayed = displayedDraftRef.current + if (displayed) drafts.update(displayed.draft, editor.getHTML()) if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) { onTypingRef.current?.() } @@ -227,7 +242,7 @@ export function SharedMessageEditor({ return false }, handlePaste: (_view, event) => { - if (disabled || isUploading) { + if (disabled || isUploading || isSending) { return false } const imageFile = getClipboardImageFile(event.clipboardData) @@ -241,6 +256,49 @@ export function SharedMessageEditor({ }, }) + useLayoutEffect(() => { + mutationGenerationRef.current += 1 + uploadingRef.current = false + setLocalUploading(false) + drafts.setScope(draftScope) + }, [drafts, draftScope]) + + useLayoutEffect(() => { + if (!editor) return + const draft = drafts.get(draftKey) + displayedDraftRef.current = { key: draftKey, draft } + editor.commands.setContent(draft.html, { emitUpdate: false }) + // Image metadata is retained separately while a placeholder upload settles. + for (const [title, uploaded] of draft.uploadedImages) { + markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages) + } + suggestionRequestRef.current += 1 + suggestionPendingRef.current = false + setSuggestedReply(null) + setIsGeneratingSuggestion(false) + setQuickReplySearch("") + isSlashTriggeredRef.current = false + quickRepliesRef.current?.onOpenChange(false) + }, [draftKey, draftScope, drafts, editor]) + + useLayoutEffect(() => { + if (!editor) return + // The parent-owned draft may finish sending after this editor was remounted. + return drafts.subscribe((draft) => { + if (editor.isDestroyed || displayedDraftRef.current?.draft !== draft) return + if (editor.getHTML() !== draft.html) editor.commands.setContent(draft.html, { emitUpdate: false }) + for (const [title, uploaded] of draft.uploadedImages) { + markEditorImageUploadedByTitle(editor, title, uploaded, draft.uploadedImages) + } + if (!draft.html) { + setSuggestedReply(null) + setQuickReplySearch("") + isSlashTriggeredRef.current = false + quickRepliesRef.current?.onOpenChange(false) + } + }) + }, [drafts, editor]) + useEffect(() => { if (!editor) { return @@ -252,42 +310,45 @@ export function SharedMessageEditor({ if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) { return } - requestAnimationFrame(() => { - editor.commands.focus() + const draft = displayedDraftRef.current?.draft + const frame = requestAnimationFrame(() => { + if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus() }) + return () => cancelAnimationFrame(frame) }, [disabled, editor, isUploading]) async function handleSend() { - if (!editor || disabled || isUploading) { - return - } + const displayed = displayedDraftRef.current + if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return + if (displayed.key !== contextRef.current.draftKey) return const rawHTML = editor.getHTML() - if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) { - return - } - const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current) - if (!isMeaningfulHTML(html)) { - return - } - await onSendRef.current(html) - editor.commands.clearContent(true) - revokeEditorObjectUrls(objectUrlsRef.current) - uploadedImagesRef.current.clear() - setQuickReplySearch("") - isSlashTriggeredRef.current = false - quickRepliesRef.current?.onOpenChange(false) - setSuggestedReply(null) - if (!isCustomer) { - requestAnimationFrame(() => { - editor.commands.focus("end") - }) + if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return + const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages) + if (!isMeaningfulHTML(html)) return + + const sent = drafts.beginSend(displayed.key) + if (!sent) return + const send = onSendRef.current + try { + await send(html) + const cleared = drafts.finishSend(sent, true) + if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return + editor.commands.setContent("", { emitUpdate: false }) + setQuickReplySearch("") + isSlashTriggeredRef.current = false + quickRepliesRef.current?.onOpenChange(false) + setSuggestedReply(null) + if (!isCustomer) focusEditorEnd() + } catch { + // The caller displays the error. Keep the entire draft, including images, for retry. + drafts.finishSend(sent, false) } } async function handleSelectImage(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" - if (!file || !editor || disabled || isUploading) { + if (!file || !editor || disabled || isUploading || isSending) { restoreFocusIfNeeded() return } @@ -295,68 +356,86 @@ export function SharedMessageEditor({ } async function insertUploadedImage(file: File) { - if (!editor || disabled || isUploading) { - return - } + const displayed = displayedDraftRef.current + if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return + const { key, draft } = displayed + const upload = onUploadImageRef.current + const generation = mutationGenerationRef.current + const isVisible = () => mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft shouldRestoreFocusRef.current = true + uploadingRef.current = true const objectUrl = URL.createObjectURL(file) - objectUrlsRef.current.add(objectUrl) + draft.objectUrls.add(objectUrl) const placeholderId = `uploading-${generateUUID()}` - editor - .chain() - .focus() - .setImage({ - src: objectUrl, - alt: file.name || "uploading-image", - title: placeholderId, - }) - .run() + editor.chain().focus().setImage({ + src: objectUrl, + alt: file.name || "uploading-image", + title: placeholderId, + }).run() setEditorImageUploadingByTitle(editor, placeholderId) + const removePlaceholder = () => { + if (!drafts.contains(key, draft)) return + if (isVisible()) { + removeEditorImageByTitle(editor, placeholderId) + } else { + const template = document.createElement("template") + template.innerHTML = draft.html + for (const image of template.content.querySelectorAll("img")) { + if (image.getAttribute("title") === placeholderId) image.remove() + } + drafts.update(draft, template.innerHTML) + drafts.notify(draft) + } + revokeEditorObjectUrl(draft.objectUrls, objectUrl) + } + try { setLocalUploading(true) - const uploaded = await onUploadImageRef.current(file) + const uploaded = await upload(file) + if (!drafts.contains(key, draft)) return if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) { - removeEditorImageByTitle(editor, placeholderId) - revokeEditorObjectUrl(objectUrlsRef.current, objectUrl) + removePlaceholder() return } - markEditorImageUploadedByTitle( - editor, - placeholderId, - uploaded, - uploadedImagesRef.current - ) + draft.uploadedImages.set(placeholderId, uploaded) + drafts.notify(draft) + } catch { + removePlaceholder() } finally { - setLocalUploading(false) - requestAnimationFrame(() => { - if (!disabled && shouldRestoreFocusRef.current) { - editor.commands.focus() - } - }) + if (generation === mutationGenerationRef.current) { + uploadingRef.current = false + if (mountedRef.current) setLocalUploading(false) + } + if (isVisible()) restoreFocusIfNeeded() } } async function handleSelectAttachment(event: ChangeEvent) { const file = event.target.files?.[0] event.target.value = "" - if (!file || disabled || isUploading) { + if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) { restoreFocusIfNeeded() return } + const draft = displayedDraftRef.current?.draft + const sendAttachment = onSendAttachmentRef.current + const generation = mutationGenerationRef.current shouldRestoreFocusRef.current = editor?.isFocused ?? true + uploadingRef.current = true try { setLocalUploading(true) - await onSendAttachmentRef.current(file) + await sendAttachment(file) + } catch { + // The caller reports failed attachment sends; no text draft is discarded. } finally { - setLocalUploading(false) - requestAnimationFrame(() => { - if (editor && !disabled && shouldRestoreFocusRef.current) { - editor.commands.focus() - } - }) + if (generation === mutationGenerationRef.current) { + uploadingRef.current = false + if (mountedRef.current) setLocalUploading(false) + } + if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded() } } @@ -396,15 +475,23 @@ export function SharedMessageEditor({ quickRepliesRef.current?.onOpenChange(false) setQuickReplySearch("") isSlashTriggeredRef.current = false + focusEditorEnd() + } + + function focusEditorEnd() { + const draft = displayedDraftRef.current?.draft requestAnimationFrame(() => { - editor.commands.focus("end") + if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) { + editor.commands.focus("end") + } }) } function restoreFocusIfNeeded() { if (editor && shouldRestoreFocusRef.current) { + const draft = displayedDraftRef.current?.draft requestAnimationFrame(() => { - editor.commands.focus() + if (mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus() }) } } @@ -412,17 +499,23 @@ export function SharedMessageEditor({ const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading) async function handleTriggerSuggest() { - if (!aiCopilot || disabled || isUploading || isGenerating) { - return - } + if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return + const requestId = ++suggestionRequestRef.current + suggestionPendingRef.current = true + const context = contextRef.current setIsGeneratingSuggestion(true) try { const text = await aiCopilot.onSuggest() - if (text && text.trim()) { + if (mountedRef.current && requestId === suggestionRequestRef.current && context.draftKey === contextRef.current.draftKey && context.draftScope === contextRef.current.draftScope && text?.trim()) { setSuggestedReply(text.trim()) } + } catch { + // The caller displays the suggestion error. Ignore any late failure here. } finally { - setIsGeneratingSuggestion(false) + if (mountedRef.current && requestId === suggestionRequestRef.current) { + suggestionPendingRef.current = false + setIsGeneratingSuggestion(false) + } } } @@ -432,9 +525,7 @@ export function SharedMessageEditor({ } editor.chain().focus().insertContent(suggestedReply).run() setSuggestedReply(null) - requestAnimationFrame(() => { - editor.commands.focus("end") - }) + focusEditorEnd() } function handleDismissSuggestedReply() { @@ -520,7 +611,7 @@ export function SharedMessageEditor({ shouldRestoreFocusRef.current = editor?.isFocused ?? true imageInputRef.current?.click() }} - disabled={disabled || isUploading} + disabled={disabled || isUploading || isSending} aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")} title={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")} > @@ -536,7 +627,7 @@ export function SharedMessageEditor({ shouldRestoreFocusRef.current = editor?.isFocused ?? true attachmentInputRef.current?.click() }} - disabled={disabled || isUploading} + disabled={disabled || isUploading || isSending} aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")} title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")} > @@ -640,21 +731,23 @@ export function SharedMessageEditor({ type="button" size="icon" onClick={() => void handleSend()} - disabled={disabled || isUploading} + aria-busy={isSending} + disabled={disabled || isUploading || isSending} aria-label={t("conversation.send")} title={t("conversation.send")} className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105" > - + {isSending ? : } ) : ( )} diff --git a/web/components/operator-readiness-indicator.tsx b/web/components/operator-readiness-indicator.tsx index 25d24d4..fe7504e 100644 --- a/web/components/operator-readiness-indicator.tsx +++ b/web/components/operator-readiness-indicator.tsx @@ -1,9 +1,9 @@ +// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics. "use client" import { AlertTriangleIcon, CheckCircle2Icon, - Loader2Icon, RefreshCwIcon, ShieldAlertIcon, SparklesIcon, @@ -11,7 +11,6 @@ import { } from "lucide-react" import Link from "next/link" import { useCallback, useEffect, useState } from "react" -import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -22,9 +21,7 @@ import { } from "@/components/ui/popover" import { useI18n } from "@/i18n/provider" import { - applySetupQuickFix, fetchSetupReadiness, - type SetupQuickFixAction, type SetupReadinessResponse, } from "@/lib/api/setup-readiness" import { cn } from "@/lib/utils" @@ -45,7 +42,6 @@ export function OperatorReadinessIndicator({ const t = useI18n() const [readiness, setReadiness] = useState(null) const [loading, setLoading] = useState(true) - const [fixing, setFixing] = useState(false) const [open, setOpen] = useState(false) const loadReadiness = useCallback(async () => { @@ -66,30 +62,14 @@ export function OperatorReadinessIndicator({ } window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) const interval = window.setInterval(() => { - void loadReadiness() - }, 45000) + if (document.visibilityState === "visible") void loadReadiness() + }, 60000) return () => { window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.clearInterval(interval) } }, [loadReadiness]) - async function handleQuickFix(action: SetupQuickFixAction) { - setFixing(true) - try { - const res = await applySetupQuickFix({ action }) - setReadiness(res.readiness) - notifySetupReadinessUpdated() - toast.success(t("setupWizard.quickFixSuccess")) - } catch (error) { - toast.error( - error instanceof Error ? error.message : t("common.operationFailed"), - ) - } finally { - setFixing(false) - } - } - if (loading && !readiness) { return null } @@ -225,43 +205,10 @@ export function OperatorReadinessIndicator({
- {!operatorOk && canEnableMyProfile ? ( - - ) : !chainOk && canQuickSetupAll ? ( - ) : null} diff --git a/web/components/session-provider.tsx b/web/components/session-provider.tsx index c00ff7a..6db9a92 100644 --- a/web/components/session-provider.tsx +++ b/web/components/session-provider.tsx @@ -1,6 +1,8 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. +// Modified for AgentDeskFixes: invalidate stale profile/sign-out work and clear private account state. "use client" -import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react" +import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react" import { fetchProfile, logout } from "@/lib/api/auth" import { @@ -11,6 +13,8 @@ import { writeSession, type AuthSession, } from "@/lib/auth" +import { createSessionRequestGuard } from "@/lib/session-request-guard" +import { resetAgentConversations } from "@/lib/stores/agent-conversations" type SessionContextValue = { session: AuthSession | null @@ -22,21 +26,41 @@ type SessionContextValue = { const SessionContext = createContext(null) +function accountKey(session: AuthSession | null) { + return session ? `${session.user.userType}:${session.user.id}` : "anonymous" +} + export function SessionProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null) const [ready, setReady] = useState(false) const [signingOut, setSigningOut] = useState(false) + const [requestGuard] = useState(() => createSessionRequestGuard(readSession)) + const identityRef = useRef(null) + const signOutPendingRef = useRef(false) + const signOutGenerationRef = useRef(0) + + const applySession = useCallback((next: AuthSession | null) => { + const nextIdentity = accountKey(next) + if (identityRef.current !== nextIdentity) { + resetAgentConversations() + identityRef.current = nextIdentity + } + setSession(next) + setReady(true) + }, []) const refreshSession = useCallback(async () => { const stored = readSession() if (!stored) { - setSession(null) - setReady(true) + requestGuard.invalidate() + applySession(null) return } + const isCurrent = requestGuard.begin(stored) try { const profile = await fetchProfile() + if (!isCurrent()) return const nextSession: AuthSession = { ...stored, user: profile.user, @@ -45,60 +69,76 @@ export function SessionProvider({ children }: { children: ReactNode }) { accessToken: profile.accessToken || stored.accessToken, expiresAt: profile.expiresAt || stored.expiresAt, } + applySession(nextSession) writeSession(nextSession) - setSession(nextSession) } catch (error) { + if (!isCurrent()) return const errorCode = (error as Error & { errorCode?: number }).errorCode if (errorCode === 3000 || errorCode === 3002) { clearSession() - setSession(null) + applySession(null) } else { - setSession(stored) + applySession(stored) } } finally { - setReady(true) + if (isCurrent()) setReady(true) } - }, []) + }, [applySession, requestGuard]) const signOut = useCallback(async () => { - if (signingOut) return + if (signOutPendingRef.current) return + signOutPendingRef.current = true + const generation = ++signOutGenerationRef.current setSigningOut(true) + requestGuard.invalidate() + // Start the request with the old token, then immediately remove local access. + const pending = logout() + clearSession() + applySession(null) try { - await logout() + await pending } catch { - clearSession() + // Local sign-out already completed; a late error cannot clear a new login. } finally { - setSession(null) - setSigningOut(false) + if (generation === signOutGenerationRef.current) { + signOutPendingRef.current = false + setSigningOut(false) + } } - }, [signingOut]) - - useEffect(() => { - void refreshSession() - }, [refreshSession]) + }, [applySession, requestGuard]) useEffect(() => { const syncSession = () => { - setSession(readSession()) - setReady(true) + requestGuard.invalidate() + const current = readSession() + if (current && signOutPendingRef.current) { + signOutGenerationRef.current += 1 + signOutPendingRef.current = false + setSigningOut(false) + } + applySession(current) } const syncOtherTab = (event: StorageEvent) => { - if (event.key === "agent-desk-session") syncSession() + if (event.key === "agent-desk-session" || event.key === null) syncSession() } window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession) window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession) window.addEventListener("storage", syncOtherTab) + applySession(readSession()) + void refreshSession() return () => { + requestGuard.invalidate() + signOutGenerationRef.current += 1 window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession) window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession) window.removeEventListener("storage", syncOtherTab) } - }, []) + }, [applySession, refreshSession, requestGuard]) return ( - {children} + {children} ) } diff --git a/web/components/support-chat/chat-shell.tsx b/web/components/support-chat/chat-shell.tsx index 4d07c60..1aebe60 100644 --- a/web/components/support-chat/chat-shell.tsx +++ b/web/components/support-chat/chat-shell.tsx @@ -1,3 +1,4 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. "use client" import { @@ -113,12 +114,14 @@ function SupportChatPanelContent() { useSupportChatSystemTheme() const messageListRef = useRef(null) + const mountedRef = useRef(false) const [isEmbedded, setIsEmbedded] = useState(false) const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false) const [isClosingConversation, setIsClosingConversation] = useState(false) const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false) const { + identityScope, title, subtitle, themeColor, @@ -129,6 +132,8 @@ function SupportChatPanelContent() { loadOlderMessages, status, error, + sending, + uploadingAsset, isOpen, isVisible, setIsOpen, @@ -144,6 +149,7 @@ function SupportChatPanelContent() { setConversationSatisfaction, } = useSupportChatStore( useShallow((state) => ({ + identityScope: state.identityScope, title: state.title, subtitle: state.subtitle, themeColor: state.themeColor, @@ -154,6 +160,8 @@ function SupportChatPanelContent() { loadOlderMessages: state.loadOlderMessages, status: state.status, error: state.error, + sending: state.sending, + uploadingAsset: state.uploadingAsset, isOpen: state.isOpen, isVisible: state.isVisible, setIsOpen: state.setIsOpen, @@ -182,6 +190,7 @@ function SupportChatPanelContent() { if (document.visibilityState !== "visible") { return } + if (useSupportChatStore.getState().conversation?.id !== conversation.id) return void markConversationRead().catch((readError) => { console.error("Failed to mark support chat conversation read", readError) }) @@ -190,9 +199,9 @@ function SupportChatPanelContent() { useEffect(() => { return bindSupportHostBridge({ onInit: () => { - if (useSupportChatStore.getState().isOpen) { - bootstrap() - } + // Clear a previous visitor's private state even while the host keeps + // this frame hidden; visibility must never race identity replacement. + void bootstrap() }, onOpen: () => { setIsOpen(true) @@ -205,14 +214,16 @@ function SupportChatPanelContent() { }, [bootstrap, setIsOpen, setIsVisible]) useEffect(() => { - bootstrap() + void bootstrap() + }, [isOpen, bootstrap]) + useEffect(() => { + mountedRef.current = true return () => { - if (!isOpen) { - disconnectSocket() - } + mountedRef.current = false + disconnectSocket() } - }, [isOpen, bootstrap, disconnectSocket]) + }, [disconnectSocket]) useEffect(() => { maybeMarkConversationRead() @@ -236,9 +247,21 @@ function SupportChatPanelContent() { } }, [maybeMarkConversationRead]) + function requireDisplayedConversation() { + const current = useSupportChatStore.getState() + if (!conversation?.id || current.identityScope !== identityScope || current.conversation?.id !== conversation.id) { + throw new Error(t("supportChat.sendUnavailable")) + } + } + async function handleSend(content: string) { + requireDisplayedConversation() + const submittedConversationId = conversation?.id await handleSendMessage(content) - messageListRef.current?.scrollToBottom() + const current = useSupportChatStore.getState() + if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) { + messageListRef.current?.scrollToBottom() + } } function handleCloseWindow() { @@ -246,7 +269,8 @@ function SupportChatPanelContent() { if (isEmbedded) { requestSupportHostMinimize() } else { - window.location.replace(getStandaloneClosedUrl()) + setIsOpen(false) + window.location.replace(getStandaloneClosedUrl(window.location.search)) } } @@ -257,6 +281,7 @@ function SupportChatPanelContent() { setIsClosingConversation(true) try { if (conversation?.id) { + requireDisplayedConversation() await closeConversation() } setIsCloseDialogOpen(false) @@ -351,15 +376,29 @@ function SupportChatPanelContent() { conversationId={conversation.id} customerToken={getCustomerSessionToken()} initialSatisfaction={conversation.satisfaction} - onSubmitted={setConversationSatisfaction} + onSubmitted={(satisfaction) => { + if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) { + setConversationSatisfaction(satisfaction) + } + }} />
) : null} { + requireDisplayedConversation() + return uploadMessageImage(file) + }} + onSendAttachment={async (file) => { + requireDisplayedConversation() + await sendAttachment(file) + }} /> diff --git a/web/components/support-chat/close-navigation.test.mjs b/web/components/support-chat/close-navigation.test.mjs index 5a6bbbb..2e5182e 100644 --- a/web/components/support-chat/close-navigation.test.mjs +++ b/web/components/support-chat/close-navigation.test.mjs @@ -1,3 +1,4 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. import assert from "node:assert/strict" import test from "node:test" import ts from "typescript" @@ -14,6 +15,7 @@ async function loadCloseNavigation() { fileName: "close-navigation.ts", }) const sandbox = { + URLSearchParams, exports: {}, module: { exports: {} }, } @@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async () assert.equal(getStandaloneClosedUrl(), "/support/chat/closed") }) + +test("dismiss and return retain the entry channel without accepting a redirect destination", async () => { + const { getStandaloneClosedUrl, getStandaloneChatUrl } = await loadCloseNavigation() + const search = "?channelId=help&externalId=visitor&returnUrl=https%3A%2F%2Fexample.org" + assert.equal(getStandaloneClosedUrl(search), `/support/chat/closed${search}`) + assert.equal(getStandaloneChatUrl(search), `/support/chat${search}`) +}) diff --git a/web/components/support-chat/close-navigation.ts b/web/components/support-chat/close-navigation.ts index 60782e0..d9eb6b8 100644 --- a/web/components/support-chat/close-navigation.ts +++ b/web/components/support-chat/close-navigation.ts @@ -1,5 +1,16 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed" -export function getStandaloneClosedUrl() { - return SUPPORT_CHAT_CLOSED_PATH +// Keep the entry context when dismissing/returning. These are always local +// paths, never a caller-supplied redirect destination. +function querySuffix(search: string) { + return search ? `?${new URLSearchParams(search).toString()}` : "" +} + +export function getStandaloneClosedUrl(search = "") { + return `${SUPPORT_CHAT_CLOSED_PATH}${querySuffix(search)}` +} + +export function getStandaloneChatUrl(search = "") { + return `/support/chat${querySuffix(search)}` } diff --git a/web/components/support-chat/customer-message-editor.tsx b/web/components/support-chat/customer-message-editor.tsx index 14a3a6a..b0ae1ba 100644 --- a/web/components/support-chat/customer-message-editor.tsx +++ b/web/components/support-chat/customer-message-editor.tsx @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. +// Modified for AgentDeskFixes: forward customer conversation identity and pending-send state. "use client" import { @@ -6,6 +8,9 @@ import { } from "@/components/chat/shared-message-editor" type CustomerMessageEditorProps = { + conversationId?: number | null + draftScope?: string + sending?: boolean disabled?: boolean uploadingAsset?: boolean onSend: (html: string) => Promise @@ -14,6 +19,9 @@ type CustomerMessageEditorProps = { } export function CustomerMessageEditor({ + conversationId, + draftScope, + sending = false, disabled = false, uploadingAsset = false, onSend, @@ -23,6 +31,9 @@ export function CustomerMessageEditor({ return ( state.selectedConversationId ) @@ -59,19 +65,23 @@ export function useAgentConversationRealtime() { const realtimeRef = useRef(null) const subscribedConversationIdRef = useRef(null) const selectedConversationIdRef = useRef(selectedConversationId) - const currentUserIdRef = useRef(readSession()?.user.id ?? 0) + const currentUserIdRef = useRef(session?.user.id ?? 0) + currentUserIdRef.current = session?.user.id ?? 0 useEffect(() => { selectedConversationIdRef.current = selectedConversationId }, [selectedConversationId]) useEffect(() => { + if (!accessToken) return + const isCurrentSession = () => readSession()?.accessToken === accessToken const realtime = createRealtimeConnectionManager({ createSocket: () => new WebSocket(createAdminWebSocketUrl()), - onStatusChange: setRealtimeStatus, + onStatusChange: (status) => { if (isCurrentSession()) setRealtimeStatus(status) }, onOpen: (socket) => { + if (!isCurrentSession()) return console.info("[agent-realtime] websocket connected", { - url: socket.url, + ...realtimeLogContext(socket.url), }) const conversationId = selectedConversationIdRef.current @@ -95,6 +105,7 @@ export function useAgentConversationRealtime() { } }, onMessage: (event, socket) => { + if (!isCurrentSession()) return try { const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope const eventType = envelope.type ?? "" @@ -182,17 +193,16 @@ export function useAgentConversationRealtime() { }, onClose: (event, socket) => { console.log("[agent-realtime] websocket closed", { - url: socket.url, + ...realtimeLogContext(socket.url), readyState: socket.readyState, code: event.code, - reason: event.reason, wasClean: event.wasClean, }) subscribedConversationIdRef.current = null }, onError: (_event, socket) => { console.log("[agent-realtime] websocket error", { - url: socket.url, + ...realtimeLogContext(socket.url), readyState: socket.readyState, }) }, @@ -225,7 +235,7 @@ export function useAgentConversationRealtime() { realtime.disconnect() subscribedConversationIdRef.current = null } - }, [setRealtimeStatus, t]) + }, [accessToken, setRealtimeStatus, t]) useEffect(() => { const socket = realtimeRef.current?.getSocket() diff --git a/web/lib/api/auth.ts b/web/lib/api/auth.ts index 03f768e..82b7bd3 100644 --- a/web/lib/api/auth.ts +++ b/web/lib/api/auth.ts @@ -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("/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() } } diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts index 665696a..93111c1 100644 --- a/web/lib/api/client.ts +++ b/web/lib/api/client.ts @@ -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(response: Response) { +async function parseResult(response: Response, requestSession: AuthSession | null) { const payload = (await response.json()) as JsonResult 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(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( 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( cache: "no-store", }) onResponse?.(response) - return await parseResult(response) + return await parseResult(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(response) + await parseResult(response, requestSession) } catch (error) { throw error } diff --git a/web/lib/api/im-session.test.mjs b/web/lib/api/im-session.test.mjs new file mode 100644 index 0000000..67b0be8 --- /dev/null +++ b/web/lib/api/im-session.test.mjs @@ -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") +}) diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index c2ee877..95167a3 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -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 +} | null = null +let pendingSessionRefresh: { + scope: string + promise: Promise +} | 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 | undefined), }, onResponse: (response) => { - if (contextKey === identityContextKey(getRuntimeImConfig())) { + if (identityScope === getCustomerIdentityScope()) { applyCustomerSessionHeaders(response) } }, @@ -428,7 +480,7 @@ function toQueryString(query?: Record) { 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("/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(`/api/conversation/${id}`, { ...createRequestOptions(), diff --git a/web/lib/api/session-isolation.test.mjs b/web/lib/api/session-isolation.test.mjs new file mode 100644 index 0000000..fbe6f90 --- /dev/null +++ b/web/lib/api/session-isolation.test.mjs @@ -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) + }) +} diff --git a/web/lib/api/setup-readiness.test.mjs b/web/lib/api/setup-readiness.test.mjs new file mode 100644 index 0000000..adf4fb8 --- /dev/null +++ b/web/lib/api/setup-readiness.test.mjs @@ -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") +}) diff --git a/web/lib/api/setup-readiness.ts b/web/lib/api/setup-readiness.ts index c84be84..11d8c01 100644 --- a/web/lib/api/setup-readiness.ts +++ b/web/lib/api/setup-readiness.ts @@ -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 | null + after: Record +} + 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("/api/dashboard/setup/readiness") } -export function simulateSetupRoute(payload: { +export async function simulateSetupRoute(payload: { channel_id?: number bypass_ai?: boolean }) { - return request( + const result = await request( "/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("/api/dashboard/setup/quick_fix", { method: "POST", diff --git a/web/lib/message-editor-drafts.test.mjs b/web/lib/message-editor-drafts.test.mjs new file mode 100644 index 0000000..7258a83 --- /dev/null +++ b/web/lib/message-editor-drafts.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { createMessageEditorDraftStore } from "./message-editor-drafts.ts" + +test("conversation drafts stay separate across A → B → A", () => { + const store = createMessageEditorDraftStore() + const a = store.get("1") + store.update(a, "

For A

") + const b = store.get("2") + assert.equal(b.html, "") + store.update(b, "

For B

") + assert.equal(store.get("1").html, "

For A

") + assert.equal(store.get("2").html, "

For B

") +}) + +test("failed send preserves text and uploaded image metadata, and can retry", () => { + const store = createMessageEditorDraftStore() + const draft = store.get("1") + store.update(draft, '

Message

') + draft.uploadedImages.set("uploading-test", { assetId: "test", provider: "local", storageKey: "test", url: "/test" }) + assert.equal(store.finishSend(store.beginSend("1"), false), false) + assert.equal(draft.html, '

Message

') + assert.equal(draft.uploadedImages.size, 1) + assert.ok(store.beginSend("1")) +}) + +test("send is single-flight, success clears only the captured draft", () => { + const store = createMessageEditorDraftStore() + const a = store.get("1") + const b = store.get("2") + store.update(a, "For A") + store.update(b, "For B") + const sent = store.beginSend("1") + assert.equal(store.beginSend("1"), null) + assert.equal(store.beginSend("2"), null) + assert.equal(store.finishSend(sent, true), true) + assert.equal(a.html, "") + assert.equal(b.html, "For B") + assert.ok(store.beginSend("2")) +}) + +test("typing during a pending send survives success, even after returning to identical text", () => { + const store = createMessageEditorDraftStore() + const draft = store.get("1") + store.update(draft, "Initial") + const sent = store.beginSend("1") + store.update(draft, "Next draft") + assert.equal(store.finishSend(sent, true), false) + assert.equal(draft.html, "Next draft") + const nextSend = store.beginSend("1") + store.update(draft, "Changed") + store.update(draft, "Next draft") + assert.equal(store.finishSend(nextSend, true), false) + assert.equal(draft.html, "Next draft") +}) + +test("identity change clears private drafts and invalidates old completions", () => { + const store = createMessageEditorDraftStore() + store.setScope("account-a") + const previous = store.get("1") + store.update(previous, "Private") + const sent = store.beginSend("1") + store.setScope("account-b") + const next = store.get("1") + store.update(next, "New account") + const nextSent = store.beginSend("1") + assert.equal(store.contains("1", previous), false) + assert.equal(store.finishSend(sent, true), false) + assert.equal(next.html, "New account") + assert.equal(store.finishSend(nextSent, true), true) +}) + +test("a newly mounted editor observes completion from the previous editor instance", () => { + const store = createMessageEditorDraftStore() + const draft = store.get("1") + store.update(draft, "Pending") + const sent = store.beginSend("1") + let rendered = draft.html + const unsubscribe = store.subscribe((changed) => { + if (changed === draft) rendered = changed.html + }) + store.finishSend(sent, true) + assert.equal(rendered, "") + unsubscribe() +}) + +test("pending status stays observable when switching or remounting editors", () => { + const store = createMessageEditorDraftStore() + store.get("1") + const states = [] + const unsubscribe = store.subscribeSending(() => states.push(store.isSending())) + const sent = store.beginSend("1") + store.get("2") + assert.equal(store.isSending(), true) + store.finishSend(sent, false) + assert.deepEqual(states, [true, false]) + unsubscribe() +}) diff --git a/web/lib/message-editor-drafts.ts b/web/lib/message-editor-drafts.ts new file mode 100644 index 0000000..96f8743 --- /dev/null +++ b/web/lib/message-editor-drafts.ts @@ -0,0 +1,95 @@ +import type { UploadedEditorImage } from "./im-editor-image" + +export type MessageEditorDraft = { + html: string + revision: number + objectUrls: Set + uploadedImages: Map +} + +type PendingDraftSend = { + key: string + draft: MessageEditorDraft + revision: number +} + +/** In-memory only: neither messages nor temporary image URLs belong in storage. */ +export function createMessageEditorDraftStore() { + const drafts = new Map() + let pendingSend: PendingDraftSend | null = null + let scope: string | undefined + const sendingListeners = new Set<() => void>() + const notifySending = () => { for (const listener of sendingListeners) listener() } + const listeners = new Set<(draft: MessageEditorDraft) => void>() + const notify = (draft: MessageEditorDraft) => { for (const listener of listeners) listener(draft) } + + function release(draft: MessageEditorDraft) { + for (const url of draft.objectUrls) URL.revokeObjectURL(url) + draft.objectUrls.clear() + draft.uploadedImages.clear() + } + + function clear() { + for (const draft of drafts.values()) release(draft) + drafts.clear() + pendingSend = null + notifySending() + } + + return { + clear, + notify, + subscribe(listener: (draft: MessageEditorDraft) => void) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + isSending() { return pendingSend !== null }, + subscribeSending(listener: () => void) { + sendingListeners.add(listener) + return () => { sendingListeners.delete(listener) } + }, + setScope(nextScope: string) { + if (scope !== nextScope) { + clear() + scope = nextScope + } + }, + get(key: string): MessageEditorDraft { + let draft = drafts.get(key) + if (!draft) { + draft = { html: "", revision: 0, objectUrls: new Set(), uploadedImages: new Map() } + drafts.set(key, draft) + } + return draft + }, + contains(key: string, draft: MessageEditorDraft) { + return drafts.get(key) === draft + }, + update(draft: MessageEditorDraft, html: string) { + draft.html = html + draft.revision += 1 + }, + beginSend(key: string): PendingDraftSend | null { + const draft = drafts.get(key) + if (!draft || pendingSend) return null + pendingSend = { key, draft, revision: draft.revision } + notifySending() + return pendingSend + }, + finishSend(sent: PendingDraftSend, succeeded: boolean) { + if (pendingSend !== sent) return false + pendingSend = null + notifySending() + if (!succeeded || drafts.get(sent.key) !== sent.draft || sent.draft.revision !== sent.revision) { + return false + } + sent.draft.html = "" + sent.draft.revision += 1 + release(sent.draft) + notify(sent.draft) + return true + }, + } +} + +export type MessageEditorDraftStore = ReturnType diff --git a/web/lib/message-file-identity.test.mjs b/web/lib/message-file-identity.test.mjs new file mode 100644 index 0000000..c8446d5 --- /dev/null +++ b/web/lib/message-file-identity.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getMessageFileIdentity } from "./message-file-identity.ts" + +const file = (content, name = "attachment.txt") => ({ name, type: "text/plain", + arrayBuffer: async () => new TextEncoder().encode(content).buffer }) + +test("reselecting identical file content keeps attachment retry identity", async () => { + assert.equal(await getMessageFileIdentity(file("same")), await getMessageFileIdentity(file("same"))) +}) + +test("equal-name equal-size files with different bytes cannot share retry identity", async () => { + assert.notEqual(await getMessageFileIdentity(file("left")), await getMessageFileIdentity(file("next"))) +}) diff --git a/web/lib/message-file-identity.ts b/web/lib/message-file-identity.ts new file mode 100644 index 0000000..8947e81 --- /dev/null +++ b/web/lib/message-file-identity.ts @@ -0,0 +1,13 @@ +/** + * Retry identity for a selected attachment. A file picker returns a fresh File + * object on reselection; metadata alone can also collide across different bytes. + */ +export async function getMessageFileIdentity(file: Pick) { + const bytes = await file.arrayBuffer() + const subtle = globalThis.crypto?.subtle + // Self-hosted HTTP deployments may lack WebCrypto. Exact bytes provide a + // collision-free in-memory fallback, rather than silently weakening the key. + const digest = subtle ? await subtle.digest("SHA-256", bytes) : bytes + const key = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") + return JSON.stringify([file.name, file.type, subtle ? "sha256" : "bytes", key]) +} diff --git a/web/lib/realtime-connection.test.mjs b/web/lib/realtime-connection.test.mjs index b11b3dd..f0c399a 100644 --- a/web/lib/realtime-connection.test.mjs +++ b/web/lib/realtime-connection.test.mjs @@ -1,3 +1,4 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. import assert from "node:assert/strict" import test from "node:test" @@ -97,3 +98,41 @@ test("beforeConnect failure triggers onConnectError and disconnect status", asyn assert.ok(statusList.includes("disconnected"), "status should include disconnected") manager.disconnect() }) + +test("disconnect invalidates a pending beforeConnect even across reconnect", async () => { + let resolveOld + let attempts = 0, created = 0 + const old = new Promise((resolve) => { resolveOld = resolve }) + const manager = createRealtimeConnectionManager({ + beforeConnect: () => ++attempts === 1 ? old : Promise.resolve(), + createSocket: () => { created++; return new MockWebSocket() }, + }) + const first = manager.connect() + manager.disconnect() + await manager.connect() + resolveOld() + await first + assert.equal(created, 1) + manager.disconnect() +}) + +test("late old socket close cannot clear current connection or notify current owner", async () => { + let closed = 0 + const sockets = [] + const manager = createRealtimeConnectionManager({ + createSocket: () => { + const socket = new MockWebSocket() + // Simulate an asynchronously arriving close event after replacement. + socket.close = () => { socket.readyState = MockWebSocket.CLOSED } + sockets.push(socket) + return socket + }, + onClose: () => { closed++ }, + }) + await manager.connect() + await manager.connect() + sockets[0].emit("close", {}) + assert.equal(manager.getSocket(), sockets[1]) + assert.equal(closed, 0) + manager.disconnect() +}) diff --git a/web/lib/realtime-connection.ts b/web/lib/realtime-connection.ts index 61205fb..c9ebd98 100644 --- a/web/lib/realtime-connection.ts +++ b/web/lib/realtime-connection.ts @@ -1,3 +1,4 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. "use client" export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected" @@ -157,15 +158,13 @@ export function createRealtimeConnectionManager( }) nextSocket.addEventListener("close", (event) => { - clearPingTimer() const isCurrentSocket = socket === nextSocket - if (isCurrentSocket) { - setSocket(null) - } - options.onClose?.(event, nextSocket) if (!isCurrentSocket) { return } + clearPingTimer() + setSocket(null) + options.onClose?.(event, nextSocket) if (canReconnect()) { scheduleReconnect() } else { @@ -184,6 +183,9 @@ export function createRealtimeConnectionManager( } const disconnect = (disconnectOptions?: DisconnectOptions) => { + // Also invalidate an in-flight beforeConnect, even if another connection + // is enabled immediately after this disconnect. + connectEpoch += 1 reconnectEnabled = disconnectOptions?.reconnect ?? false clearTimers() if (!reconnectEnabled) { diff --git a/web/lib/realtime-log-context.test.mjs b/web/lib/realtime-log-context.test.mjs new file mode 100644 index 0000000..8ba70ad --- /dev/null +++ b/web/lib/realtime-log-context.test.mjs @@ -0,0 +1,11 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { realtimeLogContext } from "./realtime-log-context.ts" + +test("socket diagnostics contain only origin and path, never URL credentials or query", () => { + assert.deepEqual(realtimeLogContext("wss://user:password@example.test/api/ws/admin?accessToken=not-a-secret#fragment"), { + origin: "wss://example.test", + path: "/api/ws/admin", + }) + assert.deepEqual(realtimeLogContext("invalid token-bearing input"), { origin: "", path: "" }) +}) diff --git a/web/lib/realtime-log-context.ts b/web/lib/realtime-log-context.ts new file mode 100644 index 0000000..9ce1a1b --- /dev/null +++ b/web/lib/realtime-log-context.ts @@ -0,0 +1,9 @@ +/** Never include WebSocket query parameters, userinfo, or arbitrary close reasons in logs. */ +export function realtimeLogContext(url: string) { + try { + const parsed = new URL(url) + return { origin: parsed.origin, path: parsed.pathname } + } catch { + return { origin: "", path: "" } + } +} diff --git a/web/lib/session-request-guard.test.mjs b/web/lib/session-request-guard.test.mjs new file mode 100644 index 0000000..bcb6b08 --- /dev/null +++ b/web/lib/session-request-guard.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createSessionRequestGuard, isSameSession } from "./session-request-guard.ts" + +const account = (id, accessToken) => ({ user: { id }, accessToken }) + +test("late profile success, error, and finally are stale after sign-out", () => { + let current = account(1, "test-session-a") + const guard = createSessionRequestGuard(() => current) + const isCurrent = guard.begin(current) + current = null + assert.equal(isCurrent(), false) +}) + +test("old account and rotated-token responses cannot clear the current session", () => { + const previous = account(1, "test-session-a") + assert.equal(isSameSession(previous, account(2, "test-session-b")), false) + assert.equal(isSameSession(previous, account(1, "test-session-rotated")), false) + assert.equal(isSameSession(null, account(1, "test-session-a")), false) + assert.equal(isSameSession(previous, previous), true) +}) + +test("only the latest profile request can commit, including same-token requests", () => { + const current = account(1, "test-session-a") + const guard = createSessionRequestGuard(() => current) + const first = guard.begin(current) + const second = guard.begin(current) + assert.equal(first(), false) + assert.equal(second(), true) + guard.invalidate() + assert.equal(second(), false) +}) diff --git a/web/lib/session-request-guard.ts b/web/lib/session-request-guard.ts new file mode 100644 index 0000000..e7c17f6 --- /dev/null +++ b/web/lib/session-request-guard.ts @@ -0,0 +1,20 @@ +// Kept transport-independent so all late session completions use the same rule. +export type SessionIdentity = { + accessToken: string + user: { id: number } +} + +export function isSameSession(expected: SessionIdentity | null, current: SessionIdentity | null) { + return Boolean(expected && current && expected.accessToken === current.accessToken && expected.user.id === current.user.id) +} + +export function createSessionRequestGuard(readCurrent: () => SessionIdentity | null) { + let generation = 0 + return { + invalidate() { generation += 1 }, + begin(session: SessionIdentity) { + const requestGeneration = ++generation + return () => generation === requestGeneration && isSameSession(session, readCurrent()) + }, + } +} diff --git a/web/lib/stores/agent-conversations.test.mjs b/web/lib/stores/agent-conversations.test.mjs new file mode 100644 index 0000000..b9c378d --- /dev/null +++ b/web/lib/stores/agent-conversations.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs" + +function createStore(initialize) { + let state + const setState = (update, replace = false) => { + const next = typeof update === "function" ? update(state) : update + state = replace ? next : { ...state, ...next } + } + state = initialize(setState, () => state) + const initial = state + return { getState: () => state, setState, getInitialState: () => initial } +} + +function fixture(api = {}) { + let session = { user: { id: 1 }, accessToken: "test-session-a" } + const loaded = loadTypeScriptModule(new URL("./agent-conversations.ts", import.meta.url), { + zustand: { create: createStore }, + "@/lib/api/agent": api, + "@/lib/auth": { readSession: () => session }, + "@/lib/utils": { generateUUID: () => "test-id" }, + "@/lib/im-message": { summarizeIMMessage: () => "test-summary" }, + "@/lib/im-message-merge": { + cursorFromLoadedImMessages: (messages) => String(messages[0]?.id ?? ""), + hasMoreAfterLatestImMessageMerge: () => false, + mergeImMessagesByIdAsc: (left, right) => [...left, ...right], + parseImMessageCursorId: Number, + }, + "@/lib/im-realtime-state": { + patchConversationList: (list) => list, + patchConversationListWithMessage: (list) => list, + }, + "@/lib/agent-activity-state": {}, + "@/lib/operator-presence-state": {}, + }) + return { + store: loaded.useAgentConversationsStore, + reset: loaded.resetAgentConversations, + set session(next) { session = next }, + } +} + +test("late conversation list cannot restore private data after reset/account change", async () => { + const old = deferred() + const f = fixture({ fetchAgentConversations: () => old.promise }) + const loading = f.store.getState().loadConversations() + f.session = { user: { id: 2 }, accessToken: "test-session-b" } + f.reset() + f.store.setState({ conversations: [{ id: 20 }], selectedConversationId: 20 }) + old.resolve({ results: [{ id: 10 }] }) + await loading + assert.equal(f.store.getState().conversations[0].id, 20) + assert.equal(f.store.getState().selectedConversationId, 20) +}) + +test("same-account relogin invalidates old load generations too", async () => { + const old = deferred() + const f = fixture({ fetchAgentMessages: () => old.promise }) + f.store.setState({ selectedConversationId: 10 }) + const loading = f.store.getState().loadMessages(10) + f.reset() + f.store.setState({ selectedConversationId: 10, messages: [{ id: 200 }] }) + old.resolve({ results: [{ id: 100 }] }) + await loading + assert.equal(f.store.getState().messages[0].id, 200) +}) + +test("operator send binds its recipient, is single-flight, and does not patch the new selection", async () => { + const response = deferred() + const calls = [] + const f = fixture({ sendAgentMessage: (payload) => { calls.push(payload); return response.promise } }) + f.store.setState({ selectedConversationId: 10 }) + assert.equal(await f.store.getState().sendMessage("Wrong recipient", 20), null) + const sending = f.store.getState().sendMessage("For A", 10) + assert.equal(await f.store.getState().sendMessage("Duplicate", 10), null) + f.store.setState({ selectedConversationId: 20, messages: [{ id: 200 }] }) + response.resolve({ id: 100, conversationId: 10 }) + await sending + assert.equal(calls.length, 1) + assert.equal(calls[0].conversationId, 10) + assert.equal(f.store.getState().messages[0].id, 200) +}) + +test("old send finally does not unlock a new account's pending send", async () => { + const old = deferred() + const f = fixture({ sendAgentMessage: () => old.promise }) + f.store.setState({ selectedConversationId: 10 }) + const sending = f.store.getState().sendMessage("For A", 10) + f.reset() + f.store.setState({ sending: true, selectedConversationId: 20 }) + old.resolve({ id: 100, conversationId: 10 }) + await sending + assert.equal(f.store.getState().sending, true) +}) + +test("attachment upload cannot send under a new account after identity change", async () => { + const upload = deferred() + let sends = 0 + const f = fixture({ + uploadAgentConversationAttachment: () => upload.promise, + sendAgentMessage: async () => { sends += 1; return { id: 100 } }, + }) + f.store.setState({ selectedConversationId: 10 }) + const pending = f.store.getState().sendAttachment({}, 10) + f.session = { user: { id: 2 }, accessToken: "test-session-b" } + f.reset() + upload.resolve({ assetId: "test", filename: "test.txt" }) + assert.equal(await pending, null) + assert.equal(sends, 0) +}) + +test("a fresh snapshot invalidates old pagination without leaving its loading flag stuck", async () => { + const oldPage = deferred() + const fresh = deferred() + const f = fixture({ fetchAgentMessages: (params) => params.cursor ? oldPage.promise : fresh.promise }) + f.store.setState({ selectedConversationId: 10, messagesCursor: "100", messagesHasMore: true }) + const older = f.store.getState().loadOlderMessages() + assert.equal(f.store.getState().messagesLoadingMore, true) + const refresh = f.store.getState().loadMessages(10) + fresh.resolve({ results: [{ id: 200 }], hasMore: true }) + await refresh + oldPage.resolve({ results: [{ id: 50 }], hasMore: false }) + await older + assert.equal(f.store.getState().messages[0].id, 200) + assert.equal(f.store.getState().messagesLoadingMore, false) +}) diff --git a/web/lib/stores/agent-conversations.ts b/web/lib/stores/agent-conversations.ts index 95d3d4d..04ae7fe 100644 --- a/web/lib/stores/agent-conversations.ts +++ b/web/lib/stores/agent-conversations.ts @@ -1,3 +1,5 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. +// Modified for AgentDeskFixes: guard private state, captured recipients, and stale async completions. "use client" import { create } from "zustand" @@ -104,9 +106,9 @@ type AgentConversationsStore = { loadOlderMessages: () => Promise syncLatestMessages: (conversationId: number) => Promise markSelectedConversationRead: () => Promise - sendMessage: (html: string) => Promise - uploadImage: (file: File) => Promise - sendAttachment: (file: File) => Promise + sendMessage: (html: string, conversationId?: number) => Promise + uploadImage: (file: File, conversationId?: number) => Promise + sendAttachment: (file: File, conversationId?: number) => Promise recallMessage: (messageId: number) => Promise applyRealtimeMessageCreated: (message: AgentMessage) => void applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void @@ -119,6 +121,22 @@ type AgentConversationsStore = { let conversationsRequestSeq = 0 let messagesRequestSeq = 0 +let sessionGeneration = 0 + +function captureSessionScope() { + const generation = sessionGeneration + const userId = readSession()?.user.id + return () => + generation === sessionGeneration && Boolean(userId) && readSession()?.user.id === userId +} + +/** Invalidates every outstanding request before removing private dashboard data. */ +export function resetAgentConversations() { + sessionGeneration += 1 + conversationsRequestSeq += 1 + messagesRequestSeq += 1 + useAgentConversationsStore.setState(useAgentConversationsStore.getInitialState(), true) +} export const useAgentConversationsStore = create((set, get) => ({ searchKeyword: "", @@ -167,6 +185,8 @@ export const useAgentConversationsStore = create((set, }, loadConversations: async () => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession()) return const requestSeq = ++conversationsRequestSeq const store = get() @@ -180,7 +200,7 @@ export const useAgentConversationsStore = create((set, ) const conversations = ensureArray(data.results) - if (requestSeq !== conversationsRequestSeq) { + if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) { return } @@ -216,7 +236,7 @@ export const useAgentConversationsStore = create((set, }) } } catch (error) { - if (requestSeq === conversationsRequestSeq) { + if (requestSeq === conversationsRequestSeq && isCurrentSession()) { set({ conversationsLoading: false }) } throw error @@ -246,10 +266,14 @@ export const useAgentConversationsStore = create((set, }, loadMessages: async (conversationId, options = {}) => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession() || get().selectedConversationId !== conversationId) return const requestSeq = ++messagesRequestSeq const store = get() const shouldShowLoading = options.forceLoading || store.messagesLoadedConversationId !== conversationId + // A fresh snapshot supersedes any older page request in the prior generation. + set({ messagesLoadingMore: false }) if (shouldShowLoading) { set({ @@ -270,7 +294,7 @@ export const useAgentConversationsStore = create((set, limit: 50, }) - if (requestSeq !== messagesRequestSeq) { + if (requestSeq !== messagesRequestSeq || !isCurrentSession()) { return } @@ -288,7 +312,7 @@ export const useAgentConversationsStore = create((set, messagesHasMore: Boolean(data.hasMore), }) } catch (error) { - if (requestSeq === messagesRequestSeq) { + if (requestSeq === messagesRequestSeq && isCurrentSession()) { set({ messagesLoading: false }) } throw error @@ -296,6 +320,9 @@ export const useAgentConversationsStore = create((set, }, loadOlderMessages: async () => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession()) return + const selectionGeneration = messagesRequestSeq const conversationId = get().selectedConversationId if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) { return @@ -312,7 +339,7 @@ export const useAgentConversationsStore = create((set, cursor: cursorId, limit: 50, }) - if (get().selectedConversationId !== conversationId) { + if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) { return } const incoming = ensureArray(data.results) @@ -328,12 +355,17 @@ export const useAgentConversationsStore = create((set, } }) } catch (error) { - set({ messagesLoadingMore: false }) + if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) { + set({ messagesLoadingMore: false }) + } throw error } }, syncLatestMessages: async (conversationId) => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession()) return + const selectionGeneration = messagesRequestSeq if (conversationId <= 0) { return } @@ -342,7 +374,7 @@ export const useAgentConversationsStore = create((set, conversationId, limit: 50, }) - if (get().selectedConversationId !== conversationId) { + if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) { return } const batch = ensureArray(data.results) @@ -370,6 +402,8 @@ export const useAgentConversationsStore = create((set, }, markSelectedConversationRead: async () => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession()) return const store = get() const conversationId = store.selectedConversationId const conversation = store.conversations.find((item) => item.id === conversationId) @@ -390,6 +424,7 @@ export const useAgentConversationsStore = create((set, set({ readingMessageId: lastMessage.id }) try { await markAgentMessageRead(conversationId, lastMessage.id) + if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return set((current) => { if (current.selectedConversationId !== conversationId) { return { readingMessageId: 0 } @@ -414,7 +449,9 @@ export const useAgentConversationsStore = create((set, } }) } catch (error) { - set({ readingMessageId: 0 }) + if (isCurrentSession() && get().readingMessageId === lastMessage.id) { + set({ readingMessageId: 0 }) + } throw error } }, @@ -513,7 +550,10 @@ export const useAgentConversationsStore = create((set, }, resyncRealtimeData: async (conversationId) => { + const isCurrentSession = captureSessionScope() + if (!isCurrentSession()) return await get().loadConversations() + if (!isCurrentSession()) return const selectedConversationId = get().selectedConversationId const targetConversationId = conversationId ?? selectedConversationId if (targetConversationId && selectedConversationId === targetConversationId) { @@ -524,10 +564,15 @@ export const useAgentConversationsStore = create((set, } }, - sendMessage: async (html) => { + sendMessage: async (html, recipientId) => { + const isCurrentSession = captureSessionScope() const trimmedContent = html.trim() const { selectedConversationId, sending } = get() - if (!selectedConversationId || !trimmedContent || sending) { + if ( + !isCurrentSession() || !selectedConversationId || + (recipientId !== undefined && recipientId !== selectedConversationId) || + !trimmedContent || sending || get().uploadingAsset + ) { return null } @@ -540,7 +585,7 @@ export const useAgentConversationsStore = create((set, clientMsgId: `agent_${generateUUID()}`, }) - if (get().selectedConversationId === selectedConversationId) { + if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) @@ -561,33 +606,45 @@ export const useAgentConversationsStore = create((set, return message } finally { - set({ sending: false }) + if (isCurrentSession()) set({ sending: false }) } }, - uploadImage: async (file) => { + uploadImage: async (file, recipientId) => { + const isCurrentSession = captureSessionScope() const { selectedConversationId, sending, uploadingAsset } = get() - if (!selectedConversationId || sending || uploadingAsset) { + if ( + !isCurrentSession() || !selectedConversationId || + (recipientId !== undefined && recipientId !== selectedConversationId) || + sending || uploadingAsset + ) { return null } set({ uploadingAsset: true }) try { - return await uploadAgentConversationImage(selectedConversationId, file) + const asset = await uploadAgentConversationImage(selectedConversationId, file) + return isCurrentSession() ? asset : null } finally { - set({ uploadingAsset: false }) + if (isCurrentSession()) set({ uploadingAsset: false }) } }, - sendAttachment: async (file) => { + sendAttachment: async (file, recipientId) => { + const isCurrentSession = captureSessionScope() const { selectedConversationId, sending, uploadingAsset } = get() - if (!selectedConversationId || sending || uploadingAsset) { + if ( + !isCurrentSession() || !selectedConversationId || + (recipientId !== undefined && recipientId !== selectedConversationId) || + sending || uploadingAsset + ) { return null } set({ uploadingAsset: true }) try { const asset = await uploadAgentConversationAttachment(selectedConversationId, file) + if (!isCurrentSession()) return null const message = await sendAgentMessage({ conversationId: selectedConversationId, messageType: "attachment", @@ -596,7 +653,7 @@ export const useAgentConversationsStore = create((set, clientMsgId: `agent_attachment_${generateUUID()}`, }) - if (get().selectedConversationId === selectedConversationId) { + if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) @@ -617,20 +674,22 @@ export const useAgentConversationsStore = create((set, return message } finally { - set({ uploadingAsset: false }) + if (isCurrentSession()) set({ uploadingAsset: false }) } }, recallMessage: async (messageId) => { + const isCurrentSession = captureSessionScope() const { selectedConversationId, recallingMessageId } = get() - if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) { + if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) { return null } set({ recallingMessageId: messageId }) try { const message = await recallAgentMessage(messageId) - if (get().selectedConversationId === selectedConversationId) { + if (!isCurrentSession()) return null + if (isCurrentSession() && get().selectedConversationId === selectedConversationId) { set((current) => { const nextMessages = current.messages.map((item) => item.id === message.id ? message : item @@ -656,11 +715,11 @@ export const useAgentConversationsStore = create((set, } }) } else { - set({ recallingMessageId: 0 }) + if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 }) } return message } catch (error) { - set({ recallingMessageId: 0 }) + if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 }) throw error } }, diff --git a/web/lib/stores/support-chat.test.mjs b/web/lib/stores/support-chat.test.mjs new file mode 100644 index 0000000..451a84d --- /dev/null +++ b/web/lib/stores/support-chat.test.mjs @@ -0,0 +1,289 @@ +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 deferred = () => { + let resolve, reject + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3, + serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0, + customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0, + agentLastReadMessageId: 0, ...extra }) +const message = (id, conversationId, extra = {}) => ({ id, conversationId, + senderType: "customer", senderId: 1, messageType: "html", content: `

${id}

`, + sendStatus: 1, customerRead: false, agentRead: false, ...extra }) +const page = (results, hasMore = false) => ({ results, hasMore }) + +// Exercise the actual store, including its asynchronous continuations, without +// connecting a socket, browser, database, or service. +function harness(overrides = {}) { + let identity = "visitor-1", uuid = 0, state, realtimeOptions + let connectCount = 0, disconnectCount = 0 + const timers = new Map() + const api = { + getCustomerIdentityScope: () => identity, + getCustomerEntryScope: () => identity, + ensureCustomerSession: async () => ({}), + fetchImWidgetConfig: async () => ({}), + fetchImConversationList: async () => page([conversation(1), conversation(2)]), + createOrMatchImConversation: async () => conversation(1), + fetchImMessages: async () => page([]), + createImConversation: async () => conversation(3), + markImMessageRead: async () => {}, + closeImConversation: async () => {}, + applyCustomerSessionRefresh: () => {}, + ...overrides, + } + const modules = new Map() + const mocks = { + zustand: { create: (initializer) => { + const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } } + state = initializer(set, () => state) + return { getState: () => state, setState: set } + } }, + "@/lib/api/im": api, + "@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content }, + "@/lib/im-realtime": { createImRealtimeConnection: () => {} }, + "@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null }, + "@/lib/utils": { generateUUID: () => `uuid-${++uuid}` }, + "@/i18n/messages": { translateCurrentMessage: (key) => key }, + "@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} }, + "@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => { + realtimeOptions = options + return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } } + } }, + } + const load = (name) => { + if (mocks[name]) return mocks[name] + if (modules.has(name)) return modules.get(name) + const url = name.startsWith("@/") + ? new URL(`../../${name.slice(2)}.ts`, import.meta.url) + : new URL(name, import.meta.url) + const source = readFileSync(url, "utf8") + const compiled = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, + } }) + const module = { exports: {} } + modules.set(name, module.exports) + vm.runInNewContext(compiled.outputText, { module, exports: module.exports, require: load, + console, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id }, + clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } }) + return module.exports + } + const store = load("./support-chat.ts").useSupportChatStore + const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true, + conversations: [conversation(1), conversation(2)], activeConversationId: 1, + conversation: conversation(1), ...extra }) + seed() + return { store, api, seed, timers, setIdentity: (value) => { identity = value }, + emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }), + get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } } +} + +for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) { + test(`${loader} cannot paint a late response into a newly selected request`, async () => { + const late = deferred() + const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) }) + h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" }) + const pending = h.store.getState()[loader]() + await h.store.getState().selectConversation(2) + late.resolve(page([message(5, 1)], true)) + await pending + assert.equal(h.store.getState().conversation.id, 2) + assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2]) + assert.equal(h.store.getState().messagesLoadingMore, false) + }) +} + +test("A to B to A selection invalidates the first A response", async () => { + const late = deferred() + let calls = 0 + const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0 + ? late.promise : Promise.resolve(page([message(30, conversationId)])) }) + const pending = h.store.getState().refreshMessages() + await h.store.getState().selectConversation(2) + await h.store.getState().selectConversation(1) + late.resolve(page([message(10, 1)])) + await pending + assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30]) +}) + +test("newer latest request wins and stale failure cannot replace its error state", async () => { + const old = deferred(), current = deferred() + let calls = 0 + const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise }) + const first = h.store.getState().refreshMessages() + const second = h.store.getState().refreshMessages() + current.resolve(page([message(30, 1)])) + await second + old.reject(new Error("stale error")) + await first + assert.equal(h.store.getState().error, "") + assert.equal(h.store.getState().messages[0].id, 30) +}) + +test("read acknowledgment updates its original list row without marking the new view", async () => { + const read = deferred() + const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) }) + const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 }) + h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] }) + const pending = h.store.getState().markConversationRead() + await h.store.getState().selectConversation(2) + read.resolve() + await pending + const state = h.store.getState() + assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0) + assert.equal(state.conversation.customerUnreadCount, 2) + assert.equal(state.messages[0].customerRead, false) +}) + +test("read acknowledgment does not erase a newer unread reply", async () => { + const read = deferred() + const h = harness({ markImMessageRead: () => read.promise }) + const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 }) + h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] }) + const pending = h.store.getState().markConversationRead() + h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 }) + read.resolve() + await pending + assert.equal(h.store.getState().conversation.customerUnreadCount, 2) + assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10) +}) + +test("send is single-flight and failed logical send keeps its idempotency key", async () => { + const first = deferred(), sent = [] + const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } }) + const pending = h.store.getState().handleSendMessage("

Hello

") + await assert.rejects(h.store.getState().handleSendMessage("

Hello

"), /sendInProgress/) + assert.equal(sent.length, 1) + first.reject(new Error("response lost")) + await assert.rejects(pending, /response lost/) + await h.store.getState().handleSendMessage("

Hello

") + assert.equal(sent[0].clientMsgId, sent[1].clientMsgId) + await h.store.getState().handleSendMessage("

Hello

") + assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId) +}) + +test("delivered send after switching updates only original request summary", async () => { + const send = deferred() + const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) }) + const pending = h.store.getState().handleSendMessage("hello") + await h.store.getState().selectConversation(2) + assert.equal(h.store.getState().sending, false) + send.resolve(message(10, 1)) + await pending + const state = h.store.getState() + assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10) + assert.equal(state.conversation.lastMessageId, 0) + assert.equal(state.messages[0].conversationId, 2) +}) + +test("close response for old selection does not close or disconnect current request", async () => { + const close = deferred() + const h = harness({ closeImConversation: () => close.promise }) + const pending = h.store.getState().closeConversation() + await h.store.getState().selectConversation(2) + close.resolve() + await pending + assert.equal(h.store.getState().conversation.status, 3) + assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4) + assert.equal(h.disconnectCount, 0) +}) + +test("identity change invalidates old delivered result even for same numeric conversation", async () => { + const send = deferred() + const h = harness({ sendImMessage: () => send.promise }) + const pending = h.store.getState().handleSendMessage("private") + h.setIdentity("visitor-2") + h.seed() + send.resolve(message(10, 1)) + await pending + assert.equal(h.store.getState().messages.length, 0) + assert.equal(h.store.getState().conversation.lastMessageId, 0) +}) + +test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => { + const config = deferred() + const h = harness({ fetchImWidgetConfig: () => config.promise }) + h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 }) + const pending = h.store.getState().bootstrap() + h.store.getState().disconnectSocket() + config.resolve({ title: "stale" }) + await pending + assert.equal(h.store.getState().conversation, null) + assert.equal(h.connectCount, 0) +}) + +test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => { + let listCalls = 0 + const config = deferred() + const h = harness({ fetchImWidgetConfig: () => config.promise, + fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } }) + h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 }) + const first = h.store.getState().bootstrap() + assert.equal(h.store.getState().bootstrap(), first) + config.resolve({}) + await first + assert.equal(h.store.getState().conversation, null) + await h.store.getState().retry() + assert.equal(h.store.getState().conversation.id, 1) + assert.equal(h.store.getState().error, "") +}) + +test("new request and request switching reconnect after transport was disconnected", async () => { + const h = harness() + h.seed({ isOpen: true, socket: null }) + await h.store.getState().startNewConversation() + assert.equal(h.store.getState().conversation.id, 3) + assert.ok(h.connectCount > 0) + const connects = h.connectCount + await h.store.getState().selectConversation(2) + assert.ok(h.connectCount > connects) +}) + +test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => { + const list = deferred() + const h = harness({ fetchImConversationList: () => list.promise, + sendImMessage: async () => message(10, 1) }) + const pending = h.store.getState().refreshConversations() + await h.store.getState().handleSendMessage("hello") + list.resolve(page([conversation(1), conversation(2)])) + await pending + assert.equal(h.store.getState().conversation.lastMessageId, 10) +}) + +test("image upload completes into captured draft even after selecting another request", async () => { + const upload = deferred() + const h = harness({ uploadImImage: () => upload.promise }) + const pending = h.store.getState().uploadMessageImage({}) + await h.store.getState().selectConversation(2) + const asset = { assetId: "image-1" } + upload.resolve(asset) + assert.equal(await pending, asset) +}) + +test("changed identity clears private state while closed, before reopening", async () => { + const h = harness() + h.seed({ messages: [message(10, 1)], isOpen: false }) + h.setIdentity("visitor-2") + await h.store.getState().bootstrap() + assert.equal(h.store.getState().conversation, null) + assert.equal(h.store.getState().conversations.length, 0) + assert.equal(h.store.getState().messages.length, 0) + assert.equal(h.store.getState().identityScope, "visitor-2") +}) + +test("reselected attachment reuses upload and logical send key after uncertain failure", async () => { + const sent = [] + let uploads = 0 + const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } }, + sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } }) + await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/) + await h.store.getState().sendAttachment({ content: "same file" }) + assert.equal(uploads, 1) + assert.equal(sent[0].clientMsgId, sent[1].clientMsgId) +}) diff --git a/web/lib/stores/support-chat.ts b/web/lib/stores/support-chat.ts index 4c3071d..81d6be7 100644 --- a/web/lib/stores/support-chat.ts +++ b/web/lib/stores/support-chat.ts @@ -1,3 +1,4 @@ +// Modified in the independent hardening fork; see docs/hardening/README.md. "use client" import { create } from "zustand" @@ -7,6 +8,8 @@ import { createImConversation, createOrMatchImConversation, ensureCustomerSession, + getCustomerEntryScope, + getCustomerIdentityScope, fetchImConversationList, fetchImMessages, fetchImWidgetConfig, @@ -48,6 +51,7 @@ import { sortConversationsByRecency, } from "@/lib/support-conversation-list" import { summarizeIMMessage } from "@/lib/im-message" +import { getMessageFileIdentity } from "@/lib/message-file-identity" import { createRealtimeConnectionManager } from "@/lib/realtime-connection" import { generateUUID } from "@/lib/utils" import { @@ -84,17 +88,17 @@ function withConversations( } /** - * Applies a field change to the request on screen by routing it through the - * list, so the switcher badge and the header never disagree about the same - * request. + * Route a captured request's changes through the list, deriving the active + * request separately so a background acknowledgment cannot change its target. */ -function withActiveConversationPatch( +function withConversationPatch( state: Pick, + conversationId: number, patch: Partial ) { const active = findConversationById( state.conversations, - state.activeConversationId + conversationId ) if (!active) { return {} @@ -143,7 +147,8 @@ function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] { function markConversationReadMessages( messages: ImMessage[], - payload: ImRealtimeEnvelope["data"] | ImRealtimeEnvelope["payload"] + payload?: Partial> ) { let next = messages if ((payload?.agentLastReadMessageId ?? 0) > 0) { @@ -166,6 +171,7 @@ function markConversationReadMessages( } export type SupportChatStore = { + identityScope: string title: string subtitle: string themeColor: string @@ -195,7 +201,7 @@ export type SupportChatStore = { setIsOpen: (isOpen: boolean) => void setIsVisible: (isVisible: boolean) => void - bootstrap: () => void + bootstrap: () => Promise disconnectSocket: () => void refreshConversations: () => Promise selectConversation: (conversationId: number) => Promise @@ -213,7 +219,6 @@ export type SupportChatStore = { retry: () => Promise } -let bootstrapToken = 0 function t(key: string) { return translateCurrentMessage(key) @@ -239,11 +244,110 @@ function buildSelectConversationPatch( messagesHasMore: false, messagesLoadingMore: false, readingMessageId: 0, + error: "", } } export const useSupportChatStore = create((set, get) => { let subscribedConversationId: number | null = null + let lifecycle = 0 + let selection = 0 + let latestRequest = 0 + let historyRequest = 0 + let listRequest = 0 + let loadedHistorySelection = -1 + let bootstrapPromise: Promise | null = null + const replyTimers = new Set>() + const pendingReads = new Map() + const pendingSends = new Set() + const pendingUploads = new Set() + const pendingCloses = new Set() + const textRetries = new Map() + const attachmentRetries = new Map>() + + const capture = () => ({ + lifecycle, + selection, + identityScope: getCustomerIdentityScope(), + conversationId: get().activeConversationId, + }) + type RequestScope = ReturnType + const isCurrentSession = (scope: RequestScope) => + scope.lifecycle === lifecycle && + scope.identityScope === get().identityScope && + scope.identityScope === getCustomerIdentityScope() + const isCurrentView = (scope: RequestScope) => + isCurrentSession(scope) && scope.selection === selection && + scope.conversationId === get().activeConversationId + const invalidate = () => { + lifecycle += 1 + selection += 1 + latestRequest += 1 + historyRequest += 1 + listRequest += 1 + bootstrapPromise = null + for (const timer of replyTimers) clearTimeout(timer) + replyTimers.clear() + pendingReads.clear() + pendingSends.clear() + pendingUploads.clear() + pendingCloses.clear() + } + const activeMutationState = (conversationId: number) => ({ + sending: pendingSends.has(conversationId), + uploadingAsset: pendingUploads.has(conversationId), + closingConversation: pendingCloses.has(conversationId), + }) + const resetChangedIdentity = () => { + const identityScope = getCustomerIdentityScope() + if (get().identityScope === identityScope) return + invalidate() + closeSocket({ reconnect: false }) + textRetries.clear() + attachmentRetries.clear() + set({ identityScope, initialized: false, conversations: [], conversation: null, + activeConversationId: 0, messages: [], messagesCursor: "", messagesHasMore: false, + messagesLoadingMore: false, readingMessageId: 0, activeAgentActivity: null, + sending: false, uploadingAsset: false, closingConversation: false, + creatingConversation: false, conversationsLoading: false, error: "" }) + } + const requireConversation = () => { + const scope = capture() + if (!isCurrentSession(scope) || !get().initialized || !scope.conversationId || + get().conversation?.status === CLOSED_IM_CONVERSATION_STATUS) { + throw new Error(t("supportChat.sendUnavailable")) + } + return scope + } + const applyDeliveredMessage = (scope: RequestScope, message: ImMessage) => { + if (!isCurrentSession(scope) || message.conversationId !== scope.conversationId) return + set((state) => { + const current = findConversationById(state.conversations, scope.conversationId) + if (!current) return {} + // A reply/newer realtime message may have arrived before this HTTP ack. + const newest = message.id >= current.lastMessageId + return { + ...withConversationPatch(state, scope.conversationId, { + customerLastReadMessageId: Math.max(current.customerLastReadMessageId, message.id), + customerUnreadCount: current.lastMessageId <= message.id ? 0 : current.customerUnreadCount, + ...(newest ? { + lastMessageId: message.id, + lastMessageAt: message.sentAt, + lastActiveAt: message.sentAt, + lastMessageSummary: summarizeIMMessage(message), + } : {}), + }), + ...(isCurrentView(scope) ? { + messages: markMessagesReadToMessageId(mergeImMessagesByIdAsc(state.messages, [message]), message.id, "customer"), + } : {}), + } + }) + if (!isCurrentView(scope) && get().activeConversationId === scope.conversationId) { + // The visitor left and returned while sending. Fetch under the new view + // generation instead of applying the old generation's message snapshot. + void get().syncLatestMessages() + } + } const sendSubscription = ( socket: WebSocket | null, @@ -271,30 +375,35 @@ export const useSupportChatStore = create((set, get) => { } } - const scheduleMessageReplyCheck = ( - conversationId: number, - sentMessageId: number - ) => { + const scheduleMessageReplyCheck = (scope: RequestScope, sentMessageId: number) => { for (const delay of [1500, 3500, 7000]) { - setTimeout(() => { + const timer = setTimeout(() => { + replyTimers.delete(timer) + if (!isCurrentView(scope)) return const current = get() - if (current.conversation?.id === conversationId) { - if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) { - connectSocket() - } - const hasNewer = current.messages.some((m) => m.id > sentMessageId) - if (!hasNewer) { - void current.syncLatestMessages() - } + if (current.isOpen && (!current.socket || current.socket.readyState !== WebSocket.OPEN)) { + connectSocket() + } + if (!current.messages.some((message) => message.id > sentMessageId)) { + void current.syncLatestMessages() } }, delay) + replyTimers.add(timer) } } const realtime = createRealtimeConnectionManager({ - beforeConnect: () => ensureCustomerSession(), + beforeConnect: async () => { + const scope = capture() + await ensureCustomerSession() + if (!isCurrentSession(scope)) { + if (scope.lifecycle === lifecycle && get().isOpen) void get().bootstrap() + throw new Error(t("supportChat.sendUnavailable")) + } + }, createSocket: createImRealtimeConnection, - canReconnect: () => Boolean(get().isOpen && get().conversation?.id), + canReconnect: () => Boolean(get().isOpen && get().initialized && get().conversation?.id && + get().identityScope === getCustomerIdentityScope()), onStatusChange: (status) => { if (get().isOpen || status === "disconnected") { set({ status }) @@ -304,6 +413,10 @@ export const useSupportChatStore = create((set, get) => { set({ socket }) }, onOpen: (socket) => { + if (!get().isOpen || !get().initialized || get().identityScope !== getCustomerIdentityScope()) { + closeSocket({ reconnect: false }) + return + } const activeConversationId = get().conversation?.id || get().activeConversationId if (activeConversationId > 0) { @@ -323,6 +436,7 @@ export const useSupportChatStore = create((set, get) => { subscribedConversationId = null }, onMessage: (messageEvent) => { + if (get().identityScope !== getCustomerIdentityScope()) return let event: ImRealtimeEnvelope try { event = JSON.parse(messageEvent.data) as ImRealtimeEnvelope @@ -374,17 +488,19 @@ export const useSupportChatStore = create((set, get) => { } set((state) => { const conversations = patchConversationListWithMessage( - state.conversations, + state.conversations.filter((item) => item.id !== message.conversationId || message.id >= item.lastMessageId), message ) + const updatedConversations = [...conversations, ...state.conversations.filter((item) => + item.id === message.conversationId && message.id < item.lastMessageId)] if (!isActive) { // Activity in a background request still has to show up in the // switcher, otherwise the visitor only finds out by opening each // request in turn. - return withConversations(state, conversations) + return withConversations(state, updatedConversations) } return { - ...withConversations(state, conversations), + ...withConversations(state, updatedConversations), messages: mergeImMessagesByIdAsc(state.messages, [message]), activeAgentActivity: null, } @@ -457,6 +573,7 @@ export const useSupportChatStore = create((set, get) => { } return { + identityScope: "", title: t("supportChat.title"), subtitle: "", themeColor: "#2563eb", @@ -484,7 +601,9 @@ export const useSupportChatStore = create((set, get) => { readingMessageId: 0, setIsOpen: (isOpen: boolean) => { + resetChangedIdentity() set({ isOpen }) + if (!isOpen) get().disconnectSocket() }, setIsVisible: (isVisible: boolean) => { @@ -492,394 +611,285 @@ export const useSupportChatStore = create((set, get) => { }, bootstrap: () => { - const token = ++bootstrapToken - + resetChangedIdentity() + const identityScope = getCustomerIdentityScope() if (!get().isOpen) { - closeSocket({ reconnect: false }) - set({ status: "disconnected" }) - return + get().disconnectSocket() + return Promise.resolve() } - + if (bootstrapPromise && get().identityScope === identityScope) return bootstrapPromise + if (get().initialized && get().identityScope === identityScope && get().conversation) { + connectSocket() + return get().syncLatestMessages() + } + invalidate() + closeSocket({ reconnect: false }) + if (get().identityScope !== identityScope) { + textRetries.clear() + attachmentRetries.clear() + } + set({ + identityScope, initialized: false, conversations: [], conversation: null, + activeConversationId: 0, activeAgentActivity: null, messages: [], messagesCursor: "", + messagesHasMore: false, messagesLoadingMore: false, readingMessageId: 0, + sending: false, uploadingAsset: false, closingConversation: false, + creatingConversation: false, conversationsLoading: false, error: "", status: "connecting", + }) + let scope = capture() const activateChat = async () => { try { - set({ error: "", status: "connecting" }) - - const widgetConfig: ImWidgetConfig = await fetchImWidgetConfig().catch( - () => ({}) - ) - if (bootstrapToken !== token || !get().isOpen) { - return - } - + const widgetConfig: ImWidgetConfig = await fetchImWidgetConfig().catch(() => ({})) + if (!isCurrentSession(scope) || !get().isOpen) return if (widgetConfig.channelId) { - setSupportChatRuntimeConfig({ - ...readSupportChatRuntimeConfig(), - channelId: - widgetConfig.channelId || readSupportChatRuntimeConfig().channelId, - }) + setSupportChatRuntimeConfig({ ...readSupportChatRuntimeConfig(), channelId: widgetConfig.channelId }) + set({ identityScope: getCustomerIdentityScope() }) + scope = capture() } - set({ - title: widgetConfig.title || t("supportChat.title"), - subtitle: widgetConfig.subtitle || "", - themeColor: widgetConfig.themeColor || "#2563eb", - promptStarters: widgetConfig.promptStarters || [], + title: widgetConfig.title || t("supportChat.title"), subtitle: widgetConfig.subtitle || "", + themeColor: widgetConfig.themeColor || "#2563eb", promptStarters: widgetConfig.promptStarters || [], }) - + const entryScope = getCustomerEntryScope() await ensureCustomerSession() - if (bootstrapToken !== token || !get().isOpen) { - return + if (scope.lifecycle !== lifecycle || entryScope !== getCustomerEntryScope() || !get().isOpen) return + const establishedIdentityScope = getCustomerIdentityScope() + if (establishedIdentityScope !== get().identityScope) { + textRetries.clear() + attachmentRetries.clear() } - - // Rejoin the newest still-open request rather than blindly creating - // one: with several requests in play, create_or_match would silently - // drop the visitor back into their first thread. - let conversations: ImConversation[] = [] - try { - const page = await fetchImConversationList({ - limit: CONVERSATION_LIST_PAGE_LIMIT, - }) - conversations = Array.isArray(page?.results) ? page.results : [] - } catch { - // A visitor who has never written to us has no request rows yet, - // and the list endpoint may legitimately refuse on a cold channel. - // Falling through to create_or_match keeps a first-time visitor - // working, which matters more here than surfacing the failure. + set({ identityScope: establishedIdentityScope }) + scope = capture() + // A failed list read is not evidence that the visitor has no requests. + // Surface the failure for retry rather than creating an accidental one. + const page = await fetchImConversationList({ limit: CONVERSATION_LIST_PAGE_LIMIT }) + if (!isCurrentSession(scope) || !get().isOpen) return + const conversations = Array.isArray(page?.results) ? page.results : [] + let resumedConversation = pickConversationToResume(conversations) + if (!resumedConversation) { + resumedConversation = await createOrMatchImConversation() + if (!isCurrentSession(scope) || !get().isOpen) return } - if (bootstrapToken !== token || !get().isOpen) { - return - } - - const resumable = pickConversationToResume(conversations) - if (resumable) { - set((state) => ({ - initialized: true, - ...withConversations(state, conversations, resumable.id), - })) - } else { - // Either a brand new visitor, or every request is closed. Both need - // a fresh thread; create_or_match is the only path that can tell. - const created = await createOrMatchImConversation() - if (bootstrapToken !== token || !get().isOpen) { - return - } - set((state) => ({ - initialized: true, - ...withConversations( - state, - [ - created, - ...conversations.filter((item) => item.id !== created.id), - ], - created.id - ), - })) - } - + const resumed = resumedConversation + selection += 1 + set((state) => ({ + initialized: true, + ...withConversations(state, [resumed, ...conversations.filter((item) => item.id !== resumed.id)], resumed.id), + })) await get().refreshMessages() - if (bootstrapToken !== token || !get().isOpen) { - return - } - + if (!isCurrentSession(scope) || !get().isOpen) return connectSocket() } catch (error) { - if (bootstrapToken !== token || !get().isOpen) { - return - } - set({ - status: "disconnected", - error: error instanceof Error ? error.message : t("supportChat.initFailed"), - }) + if (!isCurrentSession(scope) || !get().isOpen) return + set({ status: "disconnected", error: error instanceof Error ? error.message : t("supportChat.initFailed") }) } } - - void activateChat() + const promise = activateChat() + bootstrapPromise = promise + void promise.finally(() => { + if (bootstrapPromise === promise) bootstrapPromise = null + }) + return promise }, disconnectSocket: () => { + invalidate() closeSocket({ reconnect: false }) + set({ initialized: false, sending: false, uploadingAsset: false, closingConversation: false, + creatingConversation: false, conversationsLoading: false, messagesLoadingMore: false, readingMessageId: 0 }) }, refreshConversations: async () => { + const scope = capture() + if (!isCurrentSession(scope)) return + const requestId = ++listRequest + const before = get().conversations + set({ conversationsLoading: true }) try { - const page = await fetchImConversationList({ - limit: CONVERSATION_LIST_PAGE_LIMIT, - }) + const page = await fetchImConversationList({ limit: CONVERSATION_LIST_PAGE_LIMIT }) + if (!isCurrentSession(scope) || requestId !== listRequest) return const results = Array.isArray(page?.results) ? page.results : [] - set((state) => withConversations(state, results)) - } catch (error) { - set({ - error: - error instanceof Error - ? error.message - : t("supportChat.loadConversationsFailed"), + set((state) => { + const merged = results.map((item) => { + const previous = findConversationById(before, item.id) + const current = findConversationById(state.conversations, item.id) + if (!previous || !current) return current ?? item + // Preserve locally/realtime changed fields since this list request. + const changes = Object.fromEntries(Object.entries(current).filter(([key, value]) => + value !== previous[key as keyof ImConversation])) + return { ...item, ...changes } + }) + return { conversationsLoading: false, ...withConversations(state, + [...merged, ...state.conversations.filter((item) => !results.some((result) => result.id === item.id))]) } }) + } catch (error) { + if (!isCurrentSession(scope) || requestId !== listRequest) return + set({ conversationsLoading: false, error: error instanceof Error ? error.message : t("supportChat.loadConversationsFailed") }) } }, selectConversation: async (conversationId: number) => { - const prevId = get().conversation?.id || get().activeConversationId + if (get().activeConversationId === conversationId) return const patch = buildSelectConversationPatch(get(), conversationId) - if (!patch) { - return - } - - // The patch clears messages and re-derives `conversation` together with - // the active id; otherwise refreshMessages would still read the previous - // request and show its history under the new header. - set({ - ...patch, - activeAgentActivity: null, - }) - - const socket = get().socket - if (socket && socket.readyState === WebSocket.OPEN) { - sendSubscription(socket, prevId, conversationId) - subscribedConversationId = conversationId - } else if (get().isOpen) { - connectSocket() - } - + if (!patch || get().identityScope !== getCustomerIdentityScope()) return + selection += 1 + set({ ...patch, ...activeMutationState(conversationId), activeAgentActivity: null }) + if (get().isOpen) connectSocket() try { await get().refreshMessages() - } catch (error) { - console.error("Failed to load support chat messages", error) + } catch { + // The scoped loader already exposes the error beside the active chat. } }, startNewConversation: async (subject?: string) => { - if (get().creatingConversation) { - return - } - - set({ creatingConversation: true, error: "", activeAgentActivity: null }) + if (get().creatingConversation) return + const scope = capture() + if (!isCurrentSession(scope) || !get().initialized) throw new Error(t("supportChat.sendUnavailable")) + set({ creatingConversation: true, error: "" }) try { - const prevId = get().conversation?.id || get().activeConversationId const created = await createImConversation(subject) + if (!isCurrentSession(scope)) return + const activate = isCurrentView(scope) + if (activate) selection += 1 set((state) => ({ creatingConversation: false, - messages: [], - messagesCursor: "", - messagesHasMore: false, - activeAgentActivity: null, - ...withConversations( - state, - [created, ...state.conversations.filter((item) => item.id !== created.id)], - created.id - ), + ...withConversations(state, [created, ...state.conversations.filter((item) => item.id !== created.id)], + activate ? created.id : undefined), + ...(activate ? { messages: [], messagesCursor: "", messagesHasMore: false, + messagesLoadingMore: false, readingMessageId: 0, activeAgentActivity: null, + ...activeMutationState(created.id) } : {}), })) - - const socket = get().socket - if (socket && socket.readyState === WebSocket.OPEN) { - sendSubscription(socket, prevId, created.id) - subscribedConversationId = created.id - } else if (get().isOpen) { - connectSocket() - } - + if (!activate) return + const createdScope = capture() + if (get().isOpen) connectSocket() await get().refreshMessages() - const latestMsgId = get().messages.at(-1)?.id ?? 0 - scheduleMessageReplyCheck(created.id, latestMsgId) + if (isCurrentView(createdScope)) scheduleMessageReplyCheck(createdScope, get().messages.at(-1)?.id ?? 0) } catch (error) { - set({ - creatingConversation: false, - error: - error instanceof Error - ? error.message - : t("supportChat.createConversationFailed"), - }) + if (isCurrentSession(scope)) set({ creatingConversation: false, + ...(isCurrentView(scope) ? { error: error instanceof Error ? error.message : t("supportChat.createConversationFailed") } : {}) }) throw error } }, refreshMessages: async () => { - const conversationId = get().conversation?.id - if (!conversationId) { - return - } - + const scope = capture() + if (!scope.conversationId || !isCurrentSession(scope)) return + const requestId = ++latestRequest try { - const page = await fetchImMessages({ - conversationId, - limit: DEFAULT_PAGE_LIMIT, - }) - if (get().conversation?.id !== conversationId) { - return - } - const results = ensureMessageList(page.results) - set({ - messages: results, - messagesCursor: cursorFromLoadedImMessages(results) || page.cursor || "", - messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, + const page = await fetchImMessages({ conversationId: scope.conversationId, limit: DEFAULT_PAGE_LIMIT }) + if (!isCurrentView(scope) || requestId !== latestRequest) return + const batch = ensureMessageList(page.results).filter((item) => item.conversationId === scope.conversationId) + const historyWasLoaded = loadedHistorySelection === selection + loadedHistorySelection = selection + set((state) => { + const merged = markConversationReadMessages(mergeImMessagesByIdAsc(state.messages, batch), state.conversation ?? undefined) + return { + messages: merged, + messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", + messagesHasMore: hasMoreAfterLatestImMessageMerge({ previousMessages: historyWasLoaded ? state.messages : [], + previousHasMore: state.messagesHasMore, merged, + apiHasMore: Boolean(page.hasMore) || batch.length >= DEFAULT_PAGE_LIMIT }), + } }) } catch (error) { - if (get().conversation?.id !== conversationId) { - return - } - set({ - error: error instanceof Error ? error.message : t("supportChat.loadMessagesFailed"), - }) + if (!isCurrentView(scope) || requestId !== latestRequest) return + set({ error: error instanceof Error ? error.message : t("supportChat.loadMessagesFailed") }) throw error } }, syncLatestMessages: async () => { - const conversationId = get().conversation?.id - if (!conversationId) { - return - } - try { - const page = await fetchImMessages({ - conversationId, - limit: DEFAULT_PAGE_LIMIT, - }) - const batch = ensureMessageList(page.results) - if (batch.length === 0) { - return - } - set((state) => { - const merged = mergeImMessagesByIdAsc(state.messages, batch) - return { - messages: merged, - messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", - messagesHasMore: hasMoreAfterLatestImMessageMerge({ - previousMessages: state.messages, - previousHasMore: state.messagesHasMore, - merged, - apiHasMore: Boolean(page.hasMore) || batch.length >= DEFAULT_PAGE_LIMIT, - }), - } - }) - } catch (error) { - set({ - error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"), - }) + await get().refreshMessages() + } catch { + // The loader reports only errors belonging to this request generation. } }, loadOlderMessages: async () => { - const conversationId = get().conversation?.id - if ( - !conversationId || - get().messagesLoadingMore || - !get().messagesHasMore - ) { - return - } - + const scope = capture() + if (!scope.conversationId || !isCurrentSession(scope) || get().messagesLoadingMore || !get().messagesHasMore) return const cursorId = parseImMessageCursorId(get().messagesCursor) - if (cursorId <= 0) { - return - } - + if (cursorId <= 0) return + const requestId = ++historyRequest set({ messagesLoadingMore: true }) try { - const page = await fetchImMessages({ - conversationId, - cursor: cursorId, - limit: DEFAULT_PAGE_LIMIT, - }) - const results = ensureMessageList(page.results) + const page = await fetchImMessages({ conversationId: scope.conversationId, cursor: cursorId, limit: DEFAULT_PAGE_LIMIT }) + if (!isCurrentView(scope) || requestId !== historyRequest) return + const results = ensureMessageList(page.results).filter((item) => item.conversationId === scope.conversationId) set((state) => { - const merged = mergeImMessagesByIdAsc( - ensureMessageList(state.messages), - results - ) - return { - messages: merged, - messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", - messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, - messagesLoadingMore: false, - } + const merged = mergeImMessagesByIdAsc(state.messages, results) + return { messages: merged, messagesCursor: cursorFromLoadedImMessages(merged) || page.cursor || "", + messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, messagesLoadingMore: false } }) } catch (error) { - set({ - messagesLoadingMore: false, - error: error instanceof Error ? error.message : t("supportChat.loadHistoryFailed"), - }) + if (!isCurrentView(scope) || requestId !== historyRequest) return + set({ messagesLoadingMore: false, error: error instanceof Error ? error.message : t("supportChat.loadHistoryFailed") }) throw error } }, markConversationRead: async () => { + const scope = capture() const state = get() const conversation = state.conversation const lastMessage = state.messages.at(-1) - if (!conversation?.id || !lastMessage) { - return - } - - if ( - conversation.customerUnreadCount <= 0 && - conversation.customerLastReadMessageId >= lastMessage.id - ) { - return - } - if (state.readingMessageId === lastMessage.id) { - return - } - + if (!isCurrentSession(scope) || !conversation || !lastMessage) return + if (conversation.customerUnreadCount <= 0 && conversation.customerLastReadMessageId >= lastMessage.id) return + if ((pendingReads.get(conversation.id) ?? 0) >= lastMessage.id) return + pendingReads.set(conversation.id, lastMessage.id) set({ readingMessageId: lastMessage.id }) try { await markImMessageRead(conversation.id, lastMessage.id) - set((current) => ({ - readingMessageId: 0, - messages: current.messages.map((item) => { - if (item.id > lastMessage.id) { - return item - } - return item.customerRead ? item : { ...item, customerRead: true } - }), - ...withActiveConversationPatch(current, { - customerUnreadCount: 0, - customerLastReadMessageId: lastMessage.id, - }), - })) - } catch (error) { - set({ readingMessageId: 0 }) - throw error + if (!isCurrentSession(scope)) return + set((current) => { + const target = findConversationById(current.conversations, conversation.id) + if (!target) return {} + return { + ...withConversationPatch(current, conversation.id, { + customerLastReadMessageId: Math.max(target.customerLastReadMessageId, lastMessage.id), + // Never erase unread replies received after this read was sent. + customerUnreadCount: target.lastMessageId <= lastMessage.id ? 0 : target.customerUnreadCount, + }), + ...(isCurrentView(scope) ? { + messages: markMessagesReadToMessageId(current.messages, lastMessage.id, "customer"), + } : {}), + } + }) + } finally { + if (isCurrentSession(scope) && pendingReads.get(conversation.id) === lastMessage.id) { + pendingReads.delete(conversation.id) + if (isCurrentView(scope)) set({ readingMessageId: 0 }) + } } }, handleSendMessage: async (content: string) => { - const conversationId = get().conversation?.id - if (!conversationId) { - return - } - + const scope = requireConversation() + if (pendingSends.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) + const retryKey = JSON.stringify([scope.identityScope, scope.conversationId, content]) + const clientMsgId = textRetries.get(retryKey) ?? `support_chat_html_${generateUUID()}` + textRetries.set(retryKey, clientMsgId) + pendingSends.add(scope.conversationId) set({ error: "", sending: true }) try { - const nextMessage = await sendImMessage({ - conversationId, - messageType: "html", - content, - clientMsgId: `support_chat_html_${generateUUID()}`, - }) - set((state) => ({ - sending: false, - messages: state.messages.some((message) => message.id === nextMessage.id) - ? state.messages.map((message) => - message.id === nextMessage.id ? nextMessage : message - ) - : [...state.messages, nextMessage], - ...withActiveConversationPatch(state, { - customerLastReadMessageId: nextMessage.id, - customerUnreadCount: 0, - lastMessageAt: nextMessage.sentAt, - lastMessageSummary: summarizeIMMessage(nextMessage), - }), - })) - if (get().isOpen && (!get().socket || get().socket?.readyState !== WebSocket.OPEN)) { - connectSocket() - } - scheduleMessageReplyCheck(conversationId, nextMessage.id) + const nextMessage = await sendImMessage({ conversationId: scope.conversationId, + messageType: "html", content, clientMsgId }) + // A successful delivery still resolves after switching requests. The + // editor clears only that submitted draft revision, not the new draft. + if (!isCurrentSession(scope)) return + textRetries.delete(retryKey) + applyDeliveredMessage(scope, nextMessage) + if (get().isOpen) connectSocket() + scheduleMessageReplyCheck(scope, nextMessage.id) } catch (error) { - set({ - sending: false, - error: error instanceof Error ? error.message : t("supportChat.sendMessageFailed"), - }) + if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.sendMessageFailed") }) throw error + } finally { + if (isCurrentSession(scope)) { + pendingSends.delete(scope.conversationId) + if (get().activeConversationId === scope.conversationId) set({ sending: false }) + } } }, @@ -888,119 +898,107 @@ export const useSupportChatStore = create((set, get) => { }, uploadMessageImage: async (file: File) => { - const conversationId = get().conversation?.id - if (!conversationId) { - return null - } - + const scope = requireConversation() + if (pendingUploads.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) + pendingUploads.add(scope.conversationId) set({ error: "", uploadingAsset: true }) try { - return await uploadImImage(conversationId, file) + const asset = await uploadImImage(scope.conversationId, file) + if (!isCurrentSession(scope)) return null + return asset } catch (error) { - set({ - error: error instanceof Error ? error.message : t("supportChat.uploadImageFailed"), - }) + if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.uploadImageFailed") }) return null } finally { - set({ uploadingAsset: false }) + if (isCurrentSession(scope)) { + pendingUploads.delete(scope.conversationId) + if (get().activeConversationId === scope.conversationId) set({ uploadingAsset: false }) + } } }, sendAttachment: async (file: File) => { - const conversationId = get().conversation?.id - if (!conversationId) { - return - } - + const scope = requireConversation() + if (pendingUploads.has(scope.conversationId)) throw new Error(t("supportChat.sendInProgress")) + const retries = attachmentRetries.get(scope.conversationId) ?? new Map() + attachmentRetries.set(scope.conversationId, retries) + pendingUploads.add(scope.conversationId) set({ error: "", uploadingAsset: true }) try { - const asset = await uploadImAttachment(conversationId, file) - const nextMessage = await sendImMessage({ - conversationId, - messageType: "attachment", - content: asset.filename, - payload: JSON.stringify({ assetId: asset.assetId }), - clientMsgId: `support_chat_attachment_${generateUUID()}`, - }) - set((state) => ({ - uploadingAsset: false, - messages: state.messages.some((message) => message.id === nextMessage.id) - ? state.messages.map((message) => - message.id === nextMessage.id ? nextMessage : message - ) - : [...state.messages, nextMessage], - ...withActiveConversationPatch(state, { - customerLastReadMessageId: nextMessage.id, - customerUnreadCount: 0, - lastMessageAt: nextMessage.sentAt, - lastMessageSummary: summarizeIMMessage(nextMessage), - }), - })) - if (get().isOpen && (!get().socket || get().socket?.readyState !== WebSocket.OPEN)) { - connectSocket() - } - scheduleMessageReplyCheck(conversationId, nextMessage.id) + const retryKey = await getMessageFileIdentity(file) + if (!isCurrentSession(scope)) throw new Error(t("supportChat.sendUnavailable")) + const attempt = retries.get(retryKey) ?? { clientMsgId: `support_chat_attachment_${generateUUID()}`, asset: undefined } + retries.set(retryKey, attempt) + attempt.asset ??= await uploadImAttachment(scope.conversationId, file) + // Never send an uploaded asset using a different session's credentials. + if (!isCurrentSession(scope)) throw new Error(t("supportChat.sendUnavailable")) + const nextMessage = await sendImMessage({ conversationId: scope.conversationId, + messageType: "attachment", content: attempt.asset.filename, + payload: JSON.stringify({ assetId: attempt.asset.assetId }), clientMsgId: attempt.clientMsgId }) + if (!isCurrentSession(scope)) return + retries.delete(retryKey) + applyDeliveredMessage(scope, nextMessage) + if (get().isOpen) connectSocket() + scheduleMessageReplyCheck(scope, nextMessage.id) } catch (error) { - set({ - uploadingAsset: false, - error: error instanceof Error ? error.message : t("supportChat.sendAttachmentFailed"), - }) + if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.sendAttachmentFailed") }) throw error + } finally { + if (isCurrentSession(scope)) { + pendingUploads.delete(scope.conversationId) + if (get().activeConversationId === scope.conversationId) set({ uploadingAsset: false }) + } } }, closeConversation: async () => { - const conversationId = get().conversation?.id - if (!conversationId) { - return - } - + const scope = requireConversation() + if (pendingCloses.has(scope.conversationId)) return + pendingCloses.add(scope.conversationId) set({ error: "", closingConversation: true }) try { - await closeImConversation(conversationId) - closeSocket({ reconnect: false }) + await closeImConversation(scope.conversationId) + if (!isCurrentSession(scope)) return set((state) => ({ - closingConversation: false, - status: "disconnected", - activeAgentActivity: null, - ...withActiveConversationPatch(state, { - status: CLOSED_IM_CONVERSATION_STATUS, - customerUnreadCount: 0, - }), + ...withConversationPatch(state, scope.conversationId, { status: CLOSED_IM_CONVERSATION_STATUS }), + ...(isCurrentView(scope) ? { activeAgentActivity: null } : {}), })) + // Keep the transport alive for the request list; closing a background + // request must not disconnect the newly selected request. } catch (error) { - set({ - closingConversation: false, - error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"), - }) + if (isCurrentView(scope)) set({ error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed") }) throw error + } finally { + if (isCurrentSession(scope)) { + pendingCloses.delete(scope.conversationId) + if (get().activeConversationId === scope.conversationId) set({ closingConversation: false }) + } } }, setConversationSatisfaction: (satisfaction: ConversationSatisfactionResponse) => { - set((state) => ({ - ...withActiveConversationPatch(state, { - satisfaction, - }), - })) + if (!get().initialized || get().identityScope !== getCustomerIdentityScope()) return + set((state) => withConversationPatch(state, satisfaction.conversationId, { satisfaction })) }, retry: async () => { - if (!get().conversation?.id) { + if (!get().initialized || !get().conversation?.id || get().identityScope !== getCustomerIdentityScope()) { + await get().bootstrap() return } - + const scope = capture() set({ error: "", status: "connecting" }) try { - await get().refreshMessages() - if (get().isOpen) { - connectSocket() + await ensureCustomerSession() + if (!isCurrentSession(scope)) { + if (scope.lifecycle === lifecycle && get().isOpen) await get().bootstrap() + return } + await get().refreshMessages() + if (isCurrentView(scope) && get().isOpen) connectSocket() } catch (error) { - set({ - status: "disconnected", - error: error instanceof Error ? error.message : t("supportChat.refreshFailed"), - }) + if (!isCurrentView(scope)) return + set({ status: "disconnected", error: error instanceof Error ? error.message : t("supportChat.refreshFailed") }) } }, } diff --git a/web/lib/test-support/load-typescript-module.mjs b/web/lib/test-support/load-typescript-module.mjs new file mode 100644 index 0000000..0abd453 --- /dev/null +++ b/web/lib/test-support/load-typescript-module.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs" +import vm from "node:vm" +import ts from "typescript" + +/** Runs the real module with explicitly supplied offline transports/dependencies. */ +export function loadTypeScriptModule(url, dependencies, globals = {}) { + const { outputText } = ts.transpileModule(readFileSync(url, "utf8"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, + fileName: url.pathname, + }) + const module = { exports: {} } + const require = (name) => { + if (!Object.hasOwn(dependencies, name)) throw new Error(`Unmocked dependency: ${name}`) + return dependencies[name] + } + vm.runInNewContext(outputText, { module, exports: module.exports, require, ...globals }, { filename: url.pathname }) + return module.exports +} + +export function deferred() { + let resolve + let reject + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 5ca274e..df07879 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -583,7 +583,12 @@ "startFirstRequest": "Start your first request", "openRequestCount": "{open} open of {total}", "loadConversationsFailed": "Could not load your requests", - "createConversationFailed": "Could not create the request" + "createConversationFailed": "Could not create the request", + "dismissedTitle": "Chat window closed", + "dismissedDescription": "Your support request has not been ended. You can return to continue the conversation.", + "returnToChat": "Return to chat", + "sendUnavailable": "This conversation is unavailable. Return to it or retry before sending.", + "sendInProgress": "A message is already being sent." }, "dashboardHome": { "rangeToday": "Today", @@ -3370,8 +3375,8 @@ "statusReady": "Ready to Receive Requests", "statusNeedsSetup": "Setup Incomplete ({ready}/{total} steps ready)", "stepsProgress": "{ready} of {total} steps ready", - "quickSetupAll": "Configure Everything in 1 Click", - "quickSetupRunning": "Applying setup...", + "quickSetupAll": "Review missing defaults", + "quickSetupRunning": "Preparing preview...", "quickFixSuccess": "Configuration updated and routing re-checked", "refresh": "Re-check Readiness", "openSettings": "Open Settings", @@ -3433,7 +3438,9 @@ "channel_no_agent": "Channel is not connected to an AI agent.", "channel_agent_disabled": "Channel points to a disabled or missing AI agent.", "channel_zero_rollout": "Channel has a 0% AI agent rollout rate.", - "no_end_to_end_path": "No channel can successfully route to an available human operator right now." + "no_end_to_end_path": "No channel can successfully route to an available human operator right now.", + "agent_unpublished": "The agent has no published revision. Publish it in agent settings before using it for a channel.", + "channel_agent_unpublished": "The channel points to an unpublished agent. Publish the agent or deliberately choose a published one." }, "operatorBlockers": { "not_authenticated": "You are not signed in.", @@ -3446,22 +3453,22 @@ "at_capacity": "You have reached your maximum active conversation limit." }, "quickFixLabels": { - "quick_setup_all": "Configure Everything in 1 Click", - "ensure_team_and_schedule": "Create/Enable Team & 24/7 Schedule", + "quick_setup_all": "Review missing defaults", + "ensure_team_and_schedule": "Review team setup", "enable_my_operator_profile": "Enable My Reception & Set Idle", "fix_ai_agent_handoff": "Fix AI Agent Handoff & Bind Teams", "ensure_default_channel": "Create/Fix Default Web Channel" }, "simulator": { "title": "Operator Route Simulator", - "description": "Simulate what happens when a customer sends a message on a channel and see every decision hop in real time.", + "description": "Read-only check of current human-only routing eligibility. AI behavior and partial rollout are not simulated, and no message is sent.", "selectChannelPlaceholder": "Select a channel to simulate...", "runSimulation": "Simulate Dispatch", "running": "Simulating...", - "outcomeSuccess": "Request WILL reach operator: {operator}", - "outcomeBlocked": "Request WILL NOT reach an operator", + "outcomeSuccess": "Eligible operator now: {operator}", + "outcomeBlocked": "Operator delivery not confirmed", "outcomes": { - "assigned_to_operator": "Success: The conversation is assigned to an idle operator.", + "assigned_to_operator": "Human-only routing currently has an eligible operator. This is a read-only check, not a delivery guarantee.", "blocked_no_channel": "Blocked: No channels exist or the requested channel was not found.", "blocked_channel_disabled": "Blocked: This channel is disabled.", "blocked_zero_rollout": "Blocked: Channel AI agent rollout rate is set to 0%.", @@ -3469,7 +3476,10 @@ "blocked_ai_only_mode": "Blocked: AI agent is set to AI-Only mode and never hands off to human operators.", "blocked_agent_no_teams": "Blocked: AI agent has no valid operator teams assigned for handoff.", "queued_off_hours_no_schedule": "Queued: Target teams have no active shift schedule right now (Off-Hours).", - "queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online." + "queued_no_eligible_operator": "Queued: Shift is active, but no idle operators with available capacity are online.", + "ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.", + "partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.", + "blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first." }, "hops": { "channel": "Channel", @@ -3491,7 +3501,10 @@ "blocked_agent_no_teams": "No active teams assigned to this agent", "queued_off_hours_no_schedule": "No active shift schedule covers the current time (Off-Hours)", "queued_no_eligible_operator": "No idle operators with available capacity in active teams", - "assigned_to_operator": "Assigned to eligible operator" + "assigned_to_operator": "Assigned to eligible operator", + "ai_execution_not_simulated": "AI behavior is not simulated; operator delivery is unverified.", + "partial_rollout_not_simulated": "Partial rollout is not simulated; operator delivery is unverified.", + "blocked_agent_unpublished": "Operator routing is not verified for an unpublished agent. Publish the agent first." }, "candidateTableTitle": "Evaluated Operator Candidates", "colOperator": "Operator", @@ -3520,6 +3533,53 @@ "headerIndicator": { "readyLabel": "Ready for Chats", "blockedLabel": "Routing Blocked ({count})" + }, + "preview": { + "review": "Review setup changes", + "title": "Review exact changes before applying", + "description": "Only the named records and fields below will change. No shifts are added. Unlisted configurations and rollout percentages remain unchanged. Applying may dispatch pending conversations after the changes commit.", + "noChanges": "No safe automatic changes are available. Use the relevant settings to choose a team, route or working hours.", + "apply": "Apply reviewed changes", + "create": "Create", + "update": "Update", + "notApplied": "The reviewed changes were not applied.", + "manageSchedules": "Manage explicit working hours" + }, + "repairFields": { + "name": "Name", + "leader_user_id": "Team leader ID", + "status": "Enabled status (0 enabled, 1 disabled)", + "user_id": "User ID", + "team_id": "Team ID / new team name", + "team_ids": "Routing teams", + "service_status": "Availability (0 idle, 1 busy)", + "auto_assign_enabled": "Automatic assignment", + "max_concurrent_count": "Concurrent conversation limit", + "priority_level": "Assignment priority", + "receive_offline_message": "Receive offline messages", + "service_mode": "Service mode (1 AI only, 2 human only, 3 AI first)", + "rollout_percent": "Agent rollout (%)", + "handoff_mode": "Handoff mode (2 team queue)", + "fallback_mode": "Fallback mode", + "auto_handoff_on_failure": "Automatic handoff on failure", + "reply_timeout_seconds": "Reply timeout (seconds)", + "max_steps": "Maximum AI steps", + "channel_id": "Channel identifier", + "channel_type": "Channel type", + "ai_agent_id": "Agent ID / new agent name", + "ai_agent_rollout_percent": "Channel rollout (%)", + "config_json": "Channel configuration" + }, + "repairWarnings": { + "schedules_unchanged": "Working hours were not changed. Configure shifts explicitly; being off duty is not a setup error.", + "active_team_required": "Select or enable a team in team settings before linking new records.", + "disabled_configuration_preserved": "The selected configuration is disabled or has zero rollout and was left unchanged.", + "agent_team_selection_required": "Choose the agent’s intended teams in agent settings; no teams were added automatically.", + "channel_selection_required": "Choose the intended agent and rollout in channel settings. Existing channel routing was preserved.", + "existing_configuration_preserved": "Existing configuration was preserved. Review any remaining readiness issues in the relevant settings.", + "dispatch_failed": "Changes were saved, but pending conversation dispatch failed. Review the queue; do not reapply the repair.", + "readiness_refresh_failed": "The readiness refresh failed. Reload diagnostics to see the current state; committed changes are not rolled back.", + "agent_publication_required": "Publish the intended agent in agent settings before creating a channel. Setup does not publish agents or bypass the publication requirement." } } } diff --git a/web/messages/en-US.json.license b/web/messages/en-US.json.license new file mode 100644 index 0000000..4d2147b --- /dev/null +++ b/web/messages/en-US.json.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: Apache-2.0 +SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes. diff --git a/web/messages/ru-RU.json b/web/messages/ru-RU.json index 6ce91a5..7a54e37 100644 --- a/web/messages/ru-RU.json +++ b/web/messages/ru-RU.json @@ -583,7 +583,12 @@ "startFirstRequest": "Создайте первое обращение", "openRequestCount": "Открыто {open} из {total}", "loadConversationsFailed": "Не удалось загрузить список обращений", - "createConversationFailed": "Не удалось создать обращение" + "createConversationFailed": "Не удалось создать обращение", + "dismissedTitle": "Окно чата закрыто", + "dismissedDescription": "Ваше обращение не завершено. Вы можете вернуться и продолжить разговор.", + "returnToChat": "Вернуться в чат", + "sendUnavailable": "Диалог недоступен. Вернитесь к нему или повторите попытку перед отправкой.", + "sendInProgress": "Сообщение уже отправляется." }, "dashboardHome": { "rangeToday": "Сегодня", @@ -3370,8 +3375,8 @@ "statusReady": "Готово к приёму обращений", "statusNeedsSetup": "Требуется настройка (готово {ready} из {total} шагов)", "stepsProgress": "Готово {ready} из {total} шагов", - "quickSetupAll": "Настроить всё в 1 клик", - "quickSetupRunning": "Применение настроек...", + "quickSetupAll": "Проверить недостающие настройки", + "quickSetupRunning": "Подготовка плана...", "quickFixSuccess": "Настройки применены, проверка маршрутизации обновлена", "refresh": "Проверить снова", "openSettings": "Открыть настройки", @@ -3433,7 +3438,9 @@ "channel_no_agent": "Канал не подключен к AI-агенту.", "channel_agent_disabled": "Канал указывает на отключенного или удаленного AI-агента.", "channel_zero_rollout": "У канала охват AI-агента равен 0%.", - "no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас." + "no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас.", + "agent_unpublished": "У агента нет опубликованной версии. Опубликуйте его в настройках перед использованием в канале.", + "channel_agent_unpublished": "Канал связан с неопубликованным агентом. Опубликуйте его или выберите опубликованного агента." }, "operatorBlockers": { "not_authenticated": "Вы не вошли в систему.", @@ -3446,22 +3453,22 @@ "at_capacity": "Вы достигли лимита одновременных диалогов." }, "quickFixLabels": { - "quick_setup_all": "Настроить всё в 1 клик", - "ensure_team_and_schedule": "Создать/включить команду и расписание 24/7", + "quick_setup_all": "Проверить недостающие настройки", + "ensure_team_and_schedule": "Проверить настройку группы", "enable_my_operator_profile": "Включить мой приём и статус «Свободен»", "fix_ai_agent_handoff": "Настроить передачу оператору и привязать команды", "ensure_default_channel": "Создать/исправить веб-канал по умолчанию" }, "simulator": { "title": "Симулятор маршрутизации к оператору", - "description": "Протестируйте путь входящего сообщения клиента через канал и узнайте результат на каждом узле маршрутизации.", + "description": "Проверка текущей доступности маршрута только с оператором без изменений. Поведение ИИ и частичный запуск не моделируются; сообщения не отправляются.", "selectChannelPlaceholder": "Выберите канал для симуляции...", "runSimulation": "Запустить симуляцию", "running": "Симуляция...", - "outcomeSuccess": "Запрос ДОЙДЁТ до оператора: {operator}", - "outcomeBlocked": "Запрос НЕ ДОЙДЁТ до оператора", + "outcomeSuccess": "Сейчас доступен оператор: {operator}", + "outcomeBlocked": "Передача оператору не подтверждена", "outcomes": { - "assigned_to_operator": "Успех: обращение назначено свободному оператору.", + "assigned_to_operator": "Для маршрута только с оператором сейчас есть подходящий сотрудник. Это проверка без изменений, не гарантия доставки.", "blocked_no_channel": "Заблокировано: каналы не созданы или запрошенный канал не найден.", "blocked_channel_disabled": "Заблокировано: данный канал отключен.", "blocked_zero_rollout": "Заблокировано: охват AI-агента в канале равен 0%.", @@ -3469,7 +3476,10 @@ "blocked_ai_only_mode": "Заблокировано: AI-агент работает в режиме «Только ИИ» и не переключает на человека.", "blocked_agent_no_teams": "Заблокировано: у AI-агента не указаны активные команды для перевода.", "queued_off_hours_no_schedule": "В очереди: у команд сейчас нет активного расписания смен (нерабочие часы).", - "queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью." + "queued_no_eligible_operator": "В очереди: смена активна, но нет свободных операторов с доступной емкостью.", + "ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.", + "partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.", + "blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его." }, "hops": { "channel": "Канал", @@ -3491,7 +3501,10 @@ "blocked_agent_no_teams": "Агенту не назначены активные команды", "queued_off_hours_no_schedule": "Нет активного расписания на текущее время (нерабочие часы)", "queued_no_eligible_operator": "В активных командах нет свободных операторов с доступной емкостью", - "assigned_to_operator": "Назначено подходящему оператору" + "assigned_to_operator": "Назначено подходящему оператору", + "ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.", + "partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.", + "blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его." }, "candidateTableTitle": "Оценка кандидатов-операторов", "colOperator": "Оператор", @@ -3520,6 +3533,53 @@ "headerIndicator": { "readyLabel": "Приём включен", "blockedLabel": "Приём заблокирован ({count})" + }, + "preview": { + "review": "Просмотреть изменения", + "title": "Проверка изменений перед применением", + "description": "Изменятся только указанные записи и поля. Смены не добавляются. Остальные настройки и доли запуска сохранятся. После сохранения ожидающие диалоги могут быть распределены.", + "noChanges": "Безопасных автоматических изменений нет. Выберите группу, маршрут или рабочие часы в соответствующих настройках.", + "apply": "Применить проверенные изменения", + "create": "Создать", + "update": "Изменить", + "notApplied": "Проверенные изменения не были применены.", + "manageSchedules": "Настроить рабочие часы" + }, + "repairFields": { + "name": "Название", + "leader_user_id": "ID руководителя группы", + "status": "Статус (0 включён, 1 отключён)", + "user_id": "ID пользователя", + "team_id": "ID группы / название новой группы", + "team_ids": "Группы маршрутизации", + "service_status": "Доступность (0 свободен, 1 занят)", + "auto_assign_enabled": "Автоматическое назначение", + "max_concurrent_count": "Лимит одновременных диалогов", + "priority_level": "Приоритет назначения", + "receive_offline_message": "Принимать сообщения офлайн", + "service_mode": "Режим (1 только ИИ, 2 только оператор, 3 сначала ИИ)", + "rollout_percent": "Доля запуска агента (%)", + "handoff_mode": "Режим передачи (2 очередь группы)", + "fallback_mode": "Резервный режим", + "auto_handoff_on_failure": "Передавать оператору при сбое", + "reply_timeout_seconds": "Тайм-аут ответа (сек.)", + "max_steps": "Максимум шагов ИИ", + "channel_id": "Идентификатор канала", + "channel_type": "Тип канала", + "ai_agent_id": "ID агента / название нового агента", + "ai_agent_rollout_percent": "Доля запуска канала (%)", + "config_json": "Настройки канала" + }, + "repairWarnings": { + "schedules_unchanged": "Рабочие часы не изменены. Настройте смены явно: время вне смены не является ошибкой настройки.", + "active_team_required": "Выберите или включите группу в настройках перед связыванием новых записей.", + "disabled_configuration_preserved": "Выбранная конфигурация отключена или имеет нулевую долю запуска и оставлена без изменений.", + "agent_team_selection_required": "Выберите нужные группы в настройках агента. Группы не добавлялись автоматически.", + "channel_selection_required": "Выберите агента и долю запуска в настройках канала. Существующая маршрутизация сохранена.", + "existing_configuration_preserved": "Существующие настройки сохранены. Проверьте оставшиеся проблемы готовности в соответствующих настройках.", + "dispatch_failed": "Изменения сохранены, но распределение ожидающих диалогов не удалось. Проверьте очередь, не применяйте исправление повторно.", + "readiness_refresh_failed": "Не удалось обновить диагностику. Загрузите её снова: сохранённые изменения не отменены.", + "agent_publication_required": "Опубликуйте нужного агента в его настройках перед созданием канала. Мастер не публикует агентов и не обходит это требование." } } } diff --git a/web/messages/ru-RU.json.license b/web/messages/ru-RU.json.license new file mode 100644 index 0000000..4d2147b --- /dev/null +++ b/web/messages/ru-RU.json.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: Apache-2.0 +SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes. diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 74bb5de..c5126c8 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -583,7 +583,12 @@ "startFirstRequest": "开始第一个请求", "openRequestCount": "进行中 {open} / 共 {total}", "loadConversationsFailed": "加载请求列表失败", - "createConversationFailed": "创建请求失败" + "createConversationFailed": "创建请求失败", + "dismissedTitle": "聊天窗口已关闭", + "dismissedDescription": "您的支持请求尚未结束,您可以返回继续对话。", + "returnToChat": "返回聊天", + "sendUnavailable": "当前对话不可用,请返回对话或重试后再发送。", + "sendInProgress": "正在发送消息,请稍候。" }, "dashboardHome": { "rangeToday": "今天", @@ -3370,8 +3375,8 @@ "statusReady": "已就绪,可正常接待请求", "statusNeedsSetup": "配置未就绪(已完成 {ready}/{total} 步)", "stepsProgress": "已就绪 {ready} / {total} 步", - "quickSetupAll": "一键完成全部基础配置", - "quickSetupRunning": "正在应用配置...", + "quickSetupAll": "预览缺失的默认配置", + "quickSetupRunning": "正在准备预览…", "quickFixSuccess": "配置已自动修复并重新检测", "refresh": "重新检测", "openSettings": "前往设置", @@ -3433,7 +3438,9 @@ "channel_no_agent": "接入渠道未关联 AI 智能体。", "channel_agent_disabled": "接入渠道关联的 AI 智能体已被禁用或不存在。", "channel_zero_rollout": "接入渠道的 AI 智能体分流比例为 0%。", - "no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。" + "no_end_to_end_path": "当前没有任何渠道能成功连通到空闲可用的人工坐席。", + "agent_unpublished": "Agent 尚无已发布版本,请先在 Agent 设置中发布后再用于渠道。", + "channel_agent_unpublished": "渠道指向未发布的 Agent。请发布该 Agent,或明确选择已发布的 Agent。" }, "operatorBlockers": { "not_authenticated": "您尚未登录系统。", @@ -3446,22 +3453,22 @@ "at_capacity": "您的当前接待会话量已达上限。" }, "quickFixLabels": { - "quick_setup_all": "一键完成全部基础配置", - "ensure_team_and_schedule": "创建/启用客服组及 24/7 排班", + "quick_setup_all": "预览缺失的默认配置", + "ensure_team_and_schedule": "预览客服组设置", "enable_my_operator_profile": "启用我的坐席并设为空闲", "fix_ai_agent_handoff": "修复 AI 转人工模式并绑定客服组", "ensure_default_channel": "创建/修复默认 Web 渠道" }, "simulator": { "title": "人工坐席路由模拟器", - "description": "模拟客户通过指定渠道进线后的完整路由流转过程,逐级查看每个节点的调度决策。", + "description": "只读检查当前仅人工路由的接待条件。不模拟 AI 行为或部分灰度,不会发送消息。", "selectChannelPlaceholder": "选择要模拟的渠道...", "runSimulation": "开始模拟分发", "running": "正在模拟...", - "outcomeSuccess": "请求将成功分配给坐席:{operator}", - "outcomeBlocked": "请求无法到达人工坐席", + "outcomeSuccess": "当前符合条件的客服:{operator}", + "outcomeBlocked": "未确认可送达人工客服", "outcomes": { - "assigned_to_operator": "成功:会话将分配给处于空闲状态的就绪坐席。", + "assigned_to_operator": "仅人工路由当前有符合条件的客服。这是只读检查,不保证实际送达。", "blocked_no_channel": "受阻:系统中没有可用渠道或指定的渠道不存在。", "blocked_channel_disabled": "受阻:该接入渠道已被禁用。", "blocked_zero_rollout": "受阻:该渠道的 AI 智能体分流比例为 0%。", @@ -3469,7 +3476,10 @@ "blocked_ai_only_mode": "受阻:AI 智能体为纯 AI 模式,禁止转接人工。", "blocked_agent_no_teams": "受阻:AI 智能体未配置有效的转接客服组。", "queued_off_hours_no_schedule": "排队中:目标客服组当前无生效排班(非工作时间)。", - "queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。" + "queued_no_eligible_operator": "排队中:当前处于工作排班内,但暂无可接待空闲坐席。", + "ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。", + "partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。", + "blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。" }, "hops": { "channel": "接入渠道", @@ -3491,7 +3501,10 @@ "blocked_agent_no_teams": "AI 智能体未关联有效客服组", "queued_off_hours_no_schedule": "当前时刻无生效中的工作排班(非工作时段)", "queued_no_eligible_operator": "当前客服组内暂无空闲且容量充足的坐席", - "assigned_to_operator": "成功分配给符合条件的坐席" + "assigned_to_operator": "成功分配给符合条件的坐席", + "ai_execution_not_simulated": "未模拟 AI 行为,无法确认是否到达人工客服。", + "partial_rollout_not_simulated": "未模拟部分灰度,无法确认是否到达人工客服。", + "blocked_agent_unpublished": "未确认未发布 Agent 的人工路由,请先发布 Agent。" }, "candidateTableTitle": "参评候选坐席明细", "colOperator": "坐席", @@ -3520,6 +3533,53 @@ "headerIndicator": { "readyLabel": "接单就绪", "blockedLabel": "接单受阻 ({count})" + }, + "preview": { + "review": "预览设置更改", + "title": "应用前确认具体更改", + "description": "仅更改下列具名记录和字段,不会添加排班。未列出的配置及灰度比例保持不变。更改提交后可能分配等待中的会话。", + "noChanges": "没有可安全自动应用的更改。请在对应设置中选择客服组、路由或工作时段。", + "apply": "应用已确认的更改", + "create": "创建", + "update": "更新", + "notApplied": "未应用已确认的更改。", + "manageSchedules": "管理明确的工作时段" + }, + "repairFields": { + "name": "名称", + "leader_user_id": "组长 ID", + "status": "启用状态(0 启用,1 禁用)", + "user_id": "用户 ID", + "team_id": "客服组 ID / 新组名称", + "team_ids": "路由客服组", + "service_status": "接待状态(0 空闲,1 忙碌)", + "auto_assign_enabled": "自动分配", + "max_concurrent_count": "并发会话上限", + "priority_level": "分配优先级", + "receive_offline_message": "接收离线消息", + "service_mode": "服务模式(1 仅 AI,2 仅人工,3 AI 优先)", + "rollout_percent": "Agent 灰度比例(%)", + "handoff_mode": "转人工模式(2 客服组队列)", + "fallback_mode": "回退模式", + "auto_handoff_on_failure": "失败时自动转人工", + "reply_timeout_seconds": "回复超时(秒)", + "max_steps": "AI 最大步骤数", + "channel_id": "渠道标识", + "channel_type": "渠道类型", + "ai_agent_id": "Agent ID / 新 Agent 名称", + "ai_agent_rollout_percent": "渠道灰度比例(%)", + "config_json": "渠道配置" + }, + "repairWarnings": { + "schedules_unchanged": "未更改工作时段。请明确配置排班;非值班时段不是配置错误。", + "active_team_required": "请先在客服组设置中选择或启用客服组,再关联新记录。", + "disabled_configuration_preserved": "所选配置已禁用或灰度比例为零,已保持不变。", + "agent_team_selection_required": "请在 Agent 设置中选择目标客服组;未自动添加任何组。", + "channel_selection_required": "请在渠道设置中选择目标 Agent 和灰度比例。已保留现有渠道路由。", + "existing_configuration_preserved": "已保留现有配置。请在对应设置中检查剩余的就绪问题。", + "dispatch_failed": "更改已保存,但等待会话分配失败。请检查队列,不要重复应用修复。", + "readiness_refresh_failed": "就绪状态刷新失败。请重新加载诊断以查看当前状态;已提交的更改未回滚。", + "agent_publication_required": "创建渠道前,请先在 Agent 设置中发布目标 Agent。设置向导不会发布 Agent 或绕过发布要求。" } } } diff --git a/web/messages/zh-CN.json.license b/web/messages/zh-CN.json.license new file mode 100644 index 0000000..4d2147b --- /dev/null +++ b/web/messages/zh-CN.json.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: Apache-2.0 +SPDX-FileComment: Modified in the independent hardening fork. See docs/hardening/README.md and MODIFIED_FILES.md for source attribution and change notes.