Compare commits

1 Commits

Author SHA1 Message Date
63e0457886 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.
2026-10-16 12:15:20 +00:00
86 changed files with 5174 additions and 1929 deletions

View File

@@ -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. - 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. - 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. - `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.

104
MODIFIED_FILES.md Normal file
View File

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

View File

@@ -1,5 +1,11 @@
# AgentDesk # AgentDesk
<!-- Modified in the independent hardening fork; upstream notices retained. -->
> 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) English | [简体中文](README_ZH.md)
An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment. An open-source AI Agent customer support system with knowledge-based answers, human handoff, ticket workflows, and self-hosted deployment.

View File

@@ -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:<jwt|hmac|guest>: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.

63
docs/hardening/README.md Normal file
View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package bootstrap package bootstrap
import ( import (
@@ -87,6 +89,7 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) {
func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) { func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange) 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) { func registerApiConversationRoutes(group *gin.RouterGroup) {

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package builders package builders
import ( import (
@@ -45,9 +47,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
ClosedBy: item.ClosedBy, ClosedBy: item.ClosedBy,
CloseReason: item.CloseReason, CloseReason: item.CloseReason,
} }
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil { ret.CustomerOnline = services.WsService.IsCustomerOnline(item.CustomerID, item.ChannelID)
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
if item.CurrentAssigneeID > 0 { if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil { if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname ret.CurrentAssigneeName = user.Nickname

View File

@@ -1,7 +1,13 @@
// Modified in the independent hardening fork; see docs/hardening.
package api package api
import ( import (
"time"
"agent-desk/internal/pkg/config" "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/httpx"
"agent-desk/internal/pkg/openidentity" "agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services" "agent-desk/internal/services"
@@ -21,6 +27,7 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
IdentityHMAC: identity.Secret, IdentityHMAC: identity.Secret,
IdentityMaxAge: identity.MaxAge(), IdentityMaxAge: identity.MaxAge(),
ChannelID: channel.ChannelID, ChannelID: channel.ChannelID,
RequireSignedIdentity: services.ChannelService.RequiresSignedIdentity(channel),
}) })
if err != nil { if err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
@@ -33,3 +40,21 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
} }
httpx.WriteJSON(ctx, resp) 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},
})
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package models package models
import ( import (
@@ -250,6 +252,10 @@ type Customer struct {
// CustomerIdentity 客户第三方身份映射表。 // CustomerIdentity 客户第三方身份映射表。
type CustomerIdentity struct { 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"` ID int64 `gorm:"primaryKey;autoIncrement"`
CustomerID int64 `gorm:"type:bigint;not null;uniqueIndex:uk_customer_external"` // 为所属客户ID。 CustomerID int64 `gorm:"type:bigint;not null;uniqueIndex:uk_customer_external"` // 为所属客户ID。
ExternalSource enums.ExternalSource `gorm:"type:varchar(30);uniqueIndex:uk_customer_external"` // 为外部身份来源 ExternalSource enums.ExternalSource `gorm:"type:varchar(30);uniqueIndex:uk_customer_external"` // 为外部身份来源

View File

@@ -1,6 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package dto package dto
import "agent-desk/internal/pkg/enums" import (
"agent-desk/internal/pkg/enums"
"time"
)
type AuthPrincipal struct { type AuthPrincipal struct {
UserID int64 UserID int64
@@ -11,16 +16,22 @@ type AuthPrincipal struct {
Status enums.Status Status enums.Status
Roles []string Roles []string
Permissions []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 { type WebChannelConfig struct {
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"` Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"` ThemeColor string `json:"themeColor"`
Position string `json:"position"` Position string `json:"position"`
Width string `json:"width"` Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"` UserTokenSecret string `json:"userTokenSecret,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"` RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
} }
type TelegramChannelConfig struct { type TelegramChannelConfig struct {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package request package request
type SetupSimulateRouteRequest struct { type SetupSimulateRouteRequest struct {
@@ -8,4 +9,6 @@ type SetupSimulateRouteRequest struct {
type SetupQuickFixRequest struct { type SetupQuickFixRequest struct {
Action string `json:"action" form:"action"` Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"` EntityID int64 `json:"entity_id" form:"entity_id"`
Apply bool `json:"apply" form:"apply"`
PreviewToken string `json:"preview_token" form:"preview_token"`
} }

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package response package response
type SetupIssueItem struct { type SetupIssueItem struct {
@@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct {
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"` CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
} }
type SetupQuickFixResponse struct { type SetupQuickFixChange struct {
AppliedActions []string `json:"applied_actions"` EntityType string `json:"entity_type"`
Readiness SetupReadinessResponse `json:"readiness"` 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"`
} }

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found" error.e0001: "AI Agent not found"
error.e0002: "AI Agent not found." error.e0002: "AI Agent not found."
error.e0003: "AI Agent not found or disabled." 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." tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional."
error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v" error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v"
error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes" 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."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "ИИ-агент не найден" error.e0001: "ИИ-агент не найден"
error.e0002: "ИИ-агент не найден." error.e0002: "ИИ-агент не найден."
error.e0003: "ИИ-агент не найден или отключён." error.e0003: "ИИ-агент не найден или отключён."
@@ -566,4 +567,12 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи
tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя." tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя."
tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно." tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно."
error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v" error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки" 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: "Не удалось сохранить настройки канала. Попробуйте ещё раз."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found" error.e0001: "AI Agent not found"
error.e0002: "AI Agent 不存在" error.e0002: "AI Agent 不存在"
error.e0003: "AI Agent 不存在或已停用" error.e0003: "AI Agent 不存在或已停用"
@@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结
tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。" tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。"
error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v" error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v"
error.setup.authRequired: "请先登录以执行配置诊断或一键修复" 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: "无法保存渠道配置,请重试。"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity package openidentity
import ( import (
@@ -269,3 +271,25 @@ func flipLastChar(signature string) string {
func itoa(value int64) string { func itoa(value int64) string {
return strconv.FormatInt(value, 10) 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") }
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity package openidentity
import ( import (
@@ -24,6 +26,9 @@ type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"` ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"` ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"` ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
} }
type UserTokenClaims struct { type UserTokenClaims struct {
@@ -47,6 +52,7 @@ type Secrets struct {
// ChannelID is bound into the signed payload so a signature obtained for one // ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another. // channel cannot be replayed on another.
ChannelID string ChannelID string
RequireSignedIdentity bool
} }
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable // 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: // issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
// //
// - The channel id is included because CustomerIdentity is keyed by // - The channel id is included so signatures cannot be replayed on a
// (source, externalId) globally, so without it a signature minted for one // different channel, even when a deployment shares an HMAC signing key.
// channel would assert the same identity on every other channel.
// - The timestamp is inside the signed bytes even though it also travels as its // - 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 // 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 // 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, ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID, ExternalID: claims.UserID,
ExternalName: claims.Name, ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil }, nil
} }
return getExternalIDUser(ctx, secrets) return getExternalIDUser(ctx, secrets)
@@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error)
ExternalSource: enums.ExternalSourceUser, ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID, ExternalID: externalID,
ExternalName: externalName, ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil }, nil
} }
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{ return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest, ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID, ExternalID: externalID,
ExternalName: externalName, ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil }, nil
} }
@@ -267,3 +278,24 @@ func decodeExternalDisplayName(s string) string {
} }
return strings.TrimSpace(dec) 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
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package repositories package repositories
import ( import (
@@ -6,7 +8,6 @@ import (
"agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -36,14 +37,11 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo
return ret 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 { func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity {
if strs.IsAnyBlank(string(externalSource), externalID) { identity, err := r.GetUniqueBy(db, "", externalSource, externalID)
return nil if err != nil { return nil }
} return identity
return r.FindOne(db, sqls.NewCnd().
Eq("external_source", externalSource).
Eq("external_id", externalID))
} }
func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity { func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity {

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -50,21 +52,27 @@ func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal {
return nil return nil
} }
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal { func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal {
principal := &dto.AuthPrincipal{ principal := buildAuthPrincipal(user, session, roles, permissions)
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Avatar: user.Avatar,
UserType: user.UserType,
Status: user.Status,
Roles: roles,
Permissions: permissions,
}
ctx.Set(authPrincipalContextKey, principal) ctx.Set(authPrincipalContextKey, principal)
return 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) { func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) {
if principal = s.GetAuthPrincipal(ctx); principal == nil { if principal = s.GetAuthPrincipal(ctx); principal == nil {
if principal, err = s.Authenticate(ctx); err != 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) 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") _ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found")
return nil, errorsx.InvalidAccountI18n("error.e0260") 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) 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") return nil, errorsx.UnauthorizedI18n("error.e0256")
} }
@@ -173,7 +181,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
principal := s.setAuthPrincipal(ctx, user, roles, permissions) principal := s.setAuthPrincipal(ctx, user, session, roles, permissions)
now := time.Now() now := time.Now()
_ = LoginSessionService.Updates(session.ID, map[string]any{ _ = LoginSessionService.Updates(session.ID, map[string]any{
@@ -184,6 +192,39 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
return principal, nil 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 { func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool {
principal := s.GetAuthPrincipal(ctx) principal := s.GetAuthPrincipal(ctx)
if principal == nil { if principal == nil {
@@ -341,7 +382,7 @@ func (s *authService) validateSessionToken(token string) (*models.LoginSession,
if session.RevokedAt != nil { if session.RevokedAt != nil {
return nil, errorsx.InvalidTokenI18n("error.e0267") 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 nil, errorsx.InvalidTokenI18n("error.e0268")
} }
return session, nil 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) { func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) {
roles := make([]models.Role, 0) return repositories.RoleRepository.FindEnabledByUser(tx, userID)
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
} }
func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) { func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) {
permissionRows := make([]struct { permissionCodes, err := repositories.PermissionRepository.FindEnabledCodesByUserRoles(tx, userID)
Code string if err != nil {
}, 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 {
return nil, err return nil, err
} }
overrideRows, err := repositories.UserPermissionRepository.FindActiveEnabledOverrides(tx, userID, time.Now())
permissionCodes := make([]string, 0, len(permissionRows)) if err != nil {
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 {
return nil, err return nil, err
} }

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -98,6 +100,13 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if current == nil || current.Status == enums.StatusDeleted { if current == nil || current.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0208") 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) item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest)
if err != nil { if err != nil {
return err return err
@@ -118,7 +127,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent { if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
columns["previous_ai_agent_rollout_percent"] = 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 // 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 { if err := json.Unmarshal([]byte(raw), cfg); err != nil {
return nil, err 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) cfg.Title = strings.TrimSpace(cfg.Title)
if cfg.Title == "" { if cfg.Title == "" {
@@ -459,7 +473,7 @@ func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.Aut
} }
configJSON = string(raw) 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, "config_json": configJSON,
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
"update_user_name": operator.Username, "update_user_name": operator.Username,
@@ -683,3 +697,36 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
Remark: strings.TrimSpace(req.Remark), Remark: strings.TrimSpace(req.Remark),
}, nil }, 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
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services package services
import ( import (
@@ -151,10 +152,12 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
if limit <= 0 { if limit <= 0 {
limit = pendingDispatchBatchLimit limit = pendingDispatchBatchLimit
} }
conversations := ConversationService.Find(sqls.NewCnd(). // Preserve the existing newest-first batch/periodic retry policy, but
Eq("status", enums.IMConversationStatusPending). // bound the SQL read and distinguish failure from an empty queue.
Eq("current_assignee_id", 0). conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit)
Desc("id")) if err != nil {
return 0, err
}
if len(conversations) == 0 { if len(conversations) == 0 {
return 0, nil return 0, nil
} }

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -112,11 +114,13 @@ func findHumanDispatchRealtimeEvent(t *testing.T, session *ClientSession, eventT
func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession { func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession {
t.Helper() t.Helper()
principal := seedRealtimeStaffPrincipal(t, sqls.DB())
session := &ClientSession{ session := &ClientSession{
ID: "test-session", ID: "test-session",
Role: realtimeRoleAdmin, Role: realtimeRoleAdmin,
Topics: map[string]struct{}{}, Principal: principal,
Send: make(chan []byte, 32), Topics: map[string]struct{}{},
Send: make(chan []byte, 32),
} }
WsService.manager.Register(session, topics) WsService.manager.Register(session, topics)
return session return session
@@ -124,6 +128,7 @@ func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *Client
func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB { func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
previousDB, previousWs := sqls.DB(), WsService
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{ NamingStrategy: schema.NamingStrategy{
@@ -161,6 +166,10 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Fatalf("auto migrate error = %v", err) t.Fatalf("auto migrate error = %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
t.Cleanup(func() {
sqls.SetDB(previousDB)
WsService = previousWs
})
return db return db
} }

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( 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 // 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. // 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 // Public identities are scoped to a server-verified issuer and channel. Legacy
// (external_source, external_id) globally, so one signed visitor reaching us // internal integrations may still use their explicitly unscoped identity mapping.
// 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.
func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) { func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) {
if cnd == nil { if cnd == nil {
cnd = sqls.NewCnd() 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 { if identity == nil {
paging := &sqls.Paging{} paging := &sqls.Paging{}
if cnd.Paging != nil { if cnd.Paging != nil {
@@ -336,6 +335,7 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
return []models.Conversation{}, paging, nil return []models.Conversation{}, paging, nil
} }
cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id") 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) list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
if list == nil { if list == nil {
list = []models.Conversation{} list = []models.Conversation{}
@@ -832,8 +832,9 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 { if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
return false return false
} }
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID) if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID { return false }
if identity == nil { identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID)
if err != nil || identity == nil {
return false return false
} }
return identity.CustomerID == conversation.CustomerID return identity.CustomerID == conversation.CustomerID

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -124,7 +126,11 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
return 0, errorsx.UnauthorizedI18n("error.e0149") return 0, errorsx.UnauthorizedI18n("error.e0149")
} }
now := time.Now() 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{ updates := map[string]any{
"last_active_at": now, "last_active_at": now,
"updated_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{ if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{
CustomerID: customer.ID, CustomerID: customer.ID,
IdentityScope: externalUser.IdentityScope,
ScopedKey: repositories.ScopedCustomerIdentityKey(externalUser.IdentityScope, externalSource, externalID),
ExternalSource: externalSource, ExternalSource: externalSource,
ExternalID: externalID, ExternalID: externalID,
Status: enums.StatusOk, Status: enums.StatusOk,
AuditFields: utils.BuildAuditFields(nil), AuditFields: utils.BuildAuditFields(nil),
}); err != 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 return customer.ID, nil
} }

View File

@@ -1,7 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
"errors" "errors"
"crypto/rand"
"encoding/hex"
"strings" "strings"
"time" "time"
@@ -42,10 +46,13 @@ type customerSessionClaims struct {
CustomerID int64 `json:"customerId"` CustomerID int64 `json:"customerId"`
CustomerName string `json:"customerName"` CustomerName string `json:"customerName"`
IdentityKey string `json:"identityKey"` IdentityKey string `json:"identityKey"`
IdentityScope string `json:"identityScope"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type CustomerSessionVerifyResult struct { type CustomerSessionVerifyResult struct {
CustomerID int64
ChannelID int64
ExternalUser *openidentity.ExternalUser ExternalUser *openidentity.ExternalUser
Token string Token string
ExpiresAt time.Time ExpiresAt time.Time
@@ -56,7 +63,18 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
if channel == nil || channel.Status != enums.StatusOk { if channel == nil || channel.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0209") 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 { if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser) id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
if err != nil { if err != nil {
@@ -68,10 +86,12 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
return nil, err return nil, err
} }
customer := CustomerService.Get(customerID) customer := CustomerService.Get(customerID)
if customer == nil || customer.Status == enums.StatusDeleted { if customer == nil || customer.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0155") 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 { if err != nil {
return nil, err return nil, err
} }
@@ -92,7 +112,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
if secret == "" { if secret == "" {
return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") 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") return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158")
} }
now := time.Now() now := time.Now()
@@ -104,6 +124,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
CustomerID: customer.ID, CustomerID: customer.ID,
CustomerName: strings.TrimSpace(customer.Name), CustomerName: strings.TrimSpace(customer.Name),
IdentityKey: s.identityKey(externalUser), IdentityKey: s.identityKey(externalUser),
IdentityScope: externalUser.IdentityScope,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt), ExpiresAt: jwt.NewNumericDate(expiresAt),
@@ -125,27 +146,11 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models
if err != nil { if err != nil {
return nil, err return nil, err
} }
if channel == nil || channel.Status != enums.StatusOk { result, err := s.verifyClaims(claims, channel)
return nil, errorsx.InvalidParamI18n("error.e0209") if err != nil { return nil, err }
} result.Token = token
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,
}
if s.shouldRefresh(claims.ExpiresAt.Time) { 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 { if err != nil {
return nil, err 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 { if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil {
return nil, errorsx.UnauthorizedI18n("error.e0161") 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 nil, errorsx.UnauthorizedI18n("error.e0161")
} }
return claims, nil return claims, nil
@@ -211,8 +216,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
default: default:
return nil, errorsx.UnauthorizedI18n("error.e0161") return nil, errorsx.UnauthorizedI18n("error.e0161")
} }
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1]) identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), claims.IdentityScope, source, parts[1])
if identity == nil || identity.CustomerID != claims.CustomerID { if err != nil || identity == nil || identity.CustomerID != claims.CustomerID {
return nil, errorsx.UnauthorizedI18n("error.e0161") return nil, errorsx.UnauthorizedI18n("error.e0161")
} }
name := strings.TrimSpace(claims.CustomerName) name := strings.TrimSpace(claims.CustomerName)
@@ -223,6 +228,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
ExternalSource: source, ExternalSource: source,
ExternalID: parts[1], ExternalID: parts[1],
ExternalName: name, ExternalName: name,
IdentityScope: claims.IdentityScope,
ChannelID: claims.ChannelID,
}, nil }, nil
} }
@@ -250,3 +257,30 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin
token, _ := params.Get(ctx, "customerSessionToken") token, _ := params.Get(ctx, "customerSessionToken")
return strings.TrimSpace(token) 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services_test package services_test
import ( import (
"encoding/json"
"errors"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -9,6 +13,7 @@ import (
"agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
"agent-desk/internal/services" "agent-desk/internal/services"
@@ -59,378 +64,274 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
return db return db
} }
func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) { func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
db := setupSetupReadinessTestDB(t) t.Helper()
user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk}
admin := models.User{ if err := db.Create(&user).Error; err != nil { t.Fatal(err) }
Username: "admin", return &dto.AuthPrincipal{
Nickname: "Admin Operator", UserID: user.ID, Username: user.Username, Nickname: user.Nickname,
Status: enums.StatusOk, Roles: []string{constants.RoleCodeAdmin},
}
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},
Permissions: []string{ Permissions: []string{
constants.PermissionAgentView.Code, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code,
constants.PermissionAgentTeamView.Code, constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code,
constants.PermissionAgentTeamScheduleView.Code, constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code,
constants.PermissionAIAgentView.Code, constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code,
constants.PermissionChannelView.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 func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse {
readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal) t.Helper()
if err != nil { preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
t.Fatalf("GetReadiness error = %v", err) 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 { for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) }
if len(step.QuickFixActions) != 0 { req.Apply = true
t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions) 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 { for _, issue := range step.Issues {
if issue.QuickFixAction != "" { if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true }
t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction) if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true }
} if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") }
} }
} }
if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") }
// 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched }
for _, action := range []string{
services.QuickFixActionQuickSetupAll, func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
services.QuickFixActionEnsureTeamSchedule, db := setupSetupReadinessTestDB(t)
services.QuickFixActionEnableMyProfile, principal := setupRepairPrincipal(t, db)
services.QuickFixActionFixAIAgentHandoff, principal.Permissions = nil
services.QuickFixActionEnsureDefaultChannel, for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} {
} { principal.Roles = []string{role}
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} {
Action: action, if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) }
}, csPrincipal) }
if err == nil { }
t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action) assertSetupCount(t, db, &models.AgentTeam{}, 0)
} }
}
func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) {
var teamCount, agentCount, channelCount, profileCount int64 db := setupSetupReadinessTestDB(t)
db.Model(&models.AgentTeam{}).Count(&teamCount) principal := setupRepairPrincipal(t, db)
db.Model(&models.AIAgent{}).Count(&agentCount) team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk}
db.Model(&models.Channel{}).Count(&channelCount) if err := db.Create(&team).Error; err != nil { t.Fatal(err) }
db.Model(&models.AgentProfile{}).Count(&profileCount) profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5}
if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 { if err := db.Create(&profile).Error; err != nil { t.Fatal(err) }
t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d", principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code}
teamCount, agentCount, channelCount, profileCount) 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)
// 3. Seed an active team + active schedule + busy profile for csUser if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) }
now := time.Now() if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") }
team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk} }
db.Create(&team)
db.Create(&models.AgentTeamSchedule{ func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
TeamID: team.ID, db := setupSetupReadinessTestDB(t)
StartAt: now.Add(-1 * time.Hour), principal := setupRepairPrincipal(t, db)
EndAt: now.Add(24 * time.Hour), for _, channelID := range []int64{0, 9999} {
Status: enums.StatusOk, result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal)
}) if err != nil { t.Fatal(err) }
db.Create(&models.AgentProfile{ encoded, err := json.Marshal(result)
UserID: csUser.ID, if err != nil { t.Fatal(err) }
TeamID: team.ID, if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) }
AgentCode: "AG-CS1", }
DisplayName: "Support Agent", applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
ServiceStatus: enums.ServiceStatusBusy, var team models.AgentTeam
MaxConcurrentCount: 5, var agent models.AIAgent
AutoAssignEnabled: true, if err := db.First(&team).Error; err != nil { t.Fatal(err) }
Status: enums.StatusOk, 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}
// Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle, if err := db.Create(&channel).Error; err != nil { t.Fatal(err) }
// but still cannot modify AI agents or channels! now := time.Now()
statusOnlyPrincipal := &dto.AuthPrincipal{ 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) }
UserID: csUser.ID, result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
Username: csUser.Username, if err != nil { t.Fatal(err) }
Nickname: csUser.Nickname, if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) }
Roles: []string{constants.RoleCodeCsUser}, if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") }
Permissions: []string{ if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) }
constants.PermissionAgentView.Code, result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
constants.PermissionAgentUpdateStatus.Code, 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 := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ if err != nil { t.Fatal(err) }
Action: services.QuickFixActionFixAIAgentHandoff, if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) }
}, statusOnlyPrincipal); err == nil { }
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff")
} func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ db := setupSetupReadinessTestDB(t)
Action: services.QuickFixActionEnsureDefaultChannel, principal := setupRepairPrincipal(t, db)
}, statusOnlyPrincipal); err == nil { req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel") 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) {
fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{ if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) }
Action: services.QuickFixActionEnableMyProfile, }); err != nil { t.Fatal(err) }
}, statusOnlyPrincipal) req.Apply, req.PreviewToken = true, preview.PreviewToken
if err != nil { result, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err) 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) }
if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow { for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes) 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") }
} }

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import "sync" import "sync"
@@ -23,6 +25,7 @@ func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []s
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if session.Closed.Load() { return len(m.sessions) }
m.sessions[session.ID] = session m.sessions[session.ID] = session
for _, topic := range defaultTopics { for _, topic := range defaultTopics {
m.subscribeLocked(session, topic) m.subscribeLocked(session, topic)
@@ -53,6 +56,7 @@ func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string)
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if session.Closed.Load() { return nil }
ret := make([]string, 0, len(topics)) ret := make([]string, 0, len(topics))
for _, topic := range topics { for _, topic := range topics {
if _, exists := session.Topics[topic]; exists { if _, exists := session.Topics[topic]; exists {
@@ -132,3 +136,12 @@ func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic st
} }
delete(session.Topics, topic) 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
}

View File

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

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -17,6 +19,7 @@ const (
realtimeWriteWait = 10 * time.Second realtimeWriteWait = 10 * time.Second
realtimePongWait = 60 * time.Second realtimePongWait = 60 * time.Second
realtimePingPeriod = 30 * time.Second realtimePingPeriod = 30 * time.Second
realtimeAuthCheckPeriod = 5 * time.Second
realtimeMaxMessageSize = 8 << 10 realtimeMaxMessageSize = 8 << 10
realtimeSendBufferSize = 64 realtimeSendBufferSize = 64
) )
@@ -28,8 +31,7 @@ const (
) )
const ( const (
realtimeTopicUserPrefix = "user:" realtimeTopicCustomerPrefix = "customer:"
realtimeTopicGuestPrefix = "guest:"
realtimeTopicAdminPrefix = "admin:" realtimeTopicAdminPrefix = "admin:"
realtimeTopicConversationPrefix = "conversation:" realtimeTopicConversationPrefix = "conversation:"
realtimeTopicNotificationPrefix = "notification:" realtimeTopicNotificationPrefix = "notification:"
@@ -315,6 +317,8 @@ type ClientSession struct {
Conn *websocket.Conn Conn *websocket.Conn
Principal *dto.AuthPrincipal Principal *dto.AuthPrincipal
External *openidentity.ExternalUser External *openidentity.ExternalUser
CustomerSession *CustomerSessionVerifyResult
sendMu sync.RWMutex
Role string Role string
TerminalType string TerminalType string
Topics map[string]struct{} Topics map[string]struct{}
@@ -325,9 +329,10 @@ type ClientSession struct {
} }
func (s *ClientSession) enqueue(payload []byte) bool { func (s *ClientSession) enqueue(payload []byte) bool {
if s == nil || s.Closed.Load() { if s == nil { return false }
return false s.sendMu.RLock()
} defer s.sendMu.RUnlock()
if s.Closed.Load() { return false }
select { select {
case s.Send <- payload: case s.Send <- payload:
return true return true
@@ -350,14 +355,3 @@ func (s *ClientSession) touch() {
} }
s.LastActiveAt.Store(time.Now().Unix()) 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
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -31,6 +33,7 @@ type wsService struct {
upgrader websocket.Upgrader upgrader websocket.Upgrader
seq atomic.Uint64 seq atomic.Uint64
manager *WsConnectionManager manager *WsConnectionManager
authCheckPeriod time.Duration
} }
func newWsService() *wsService { func newWsService() *wsService {
@@ -41,6 +44,7 @@ func newWsService() *wsService {
}, },
}, },
manager: newWsConnectionManager(), 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"))) ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return 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 { if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path) slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort() ctx.Abort()
@@ -77,29 +85,35 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
return return
} }
var ( // The customer endpoint always authenticates the customer credential. A staff
principal = AuthService.GetAuthPrincipal(ctx) // cookie/bearer must not turn this into a second dashboard subscription path.
external *openidentity.ExternalUser result, err := CustomerSessionService.VerifyRequest(ctx, channel)
customerSessionInfo *CustomerSessionVerifyResult if err != nil {
) ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
if principal == nil { return
result, err := CustomerSessionService.VerifyRequest(ctx, channel) }
if err != nil { if err := s.upgradeConnection(ctx, nil, result.ExternalUser, realtimeRoleUser, result); err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err)) slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channel_id", channel.ID)
return ctx.Abort()
} 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
}
} }
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error { 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 { if err != nil {
return err return err
} }
@@ -114,9 +128,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
Topics: make(map[string]struct{}), Topics: make(map[string]struct{}),
Send: make(chan []byte, realtimeSendBufferSize), 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.SetReadDeadline(time.Now().Add(realtimePongWait))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
session.touch() session.touch()
@@ -139,13 +154,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
"userId", logUserID, "userId", logUserID,
"externalId", logExternalID, "externalId", logExternalID,
"terminalType", session.TerminalType, "terminalType", session.TerminalType,
"topicCount", len(session.Topics), "topicCount", len(s.manager.TopicList(session)),
"sessionCount", sessionCount, "sessionCount", sessionCount,
) )
go s.writePump(session)
go s.readPump(session)
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{ session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
Payload: RealtimeConnectedPayload{ Payload: RealtimeConnectedPayload{
ConnID: session.ID, ConnID: session.ID,
@@ -153,7 +165,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
GuestID: logExternalID, GuestID: logExternalID,
Role: role, Role: role,
TerminalType: session.TerminalType, TerminalType: session.TerminalType,
Topics: session.topicList(), Topics: s.manager.TopicList(session),
}, },
})) }))
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed { 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 return nil
} }
@@ -181,6 +195,7 @@ func (s *wsService) readPump(session *ClientSession) {
} }
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) { func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
if !s.revalidateSession(session) { return }
input := realtimeClientMessage{} input := realtimeClientMessage{}
if err := json.Unmarshal(payload, &input); err != nil { if err := json.Unmarshal(payload, &input); err != nil {
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{ session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
@@ -214,7 +229,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
"eventId", strings.TrimSpace(input.EventID), "eventId", strings.TrimSpace(input.EventID),
) )
case enums.IMRealtimeClientTypePresence: 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) action := strings.TrimSpace(input.Action)
if action == "" { if action == "" {
action = "viewing" action = "viewing"
@@ -232,8 +247,10 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
func (s *wsService) writePump(session *ClientSession) { func (s *wsService) writePump(session *ClientSession) {
ticker := time.NewTicker(realtimePingPeriod) ticker := time.NewTicker(realtimePingPeriod)
authTicker := time.NewTicker(s.authCheckPeriod)
defer func() { defer func() {
ticker.Stop() ticker.Stop()
authTicker.Stop()
s.closeSession(session) s.closeSession(session)
}() }()
@@ -245,9 +262,16 @@ func (s *wsService) writePump(session *ClientSession) {
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{}) _ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return 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 return
} }
case <-authTicker.C:
if !s.revalidateSession(session) { return }
case <-ticker.C: case <-ticker.C:
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait)) _ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -262,10 +286,12 @@ func (s *wsService) closeSession(session *ClientSession) {
return return
} }
session.closeOnce.Do(func() { session.closeOnce.Do(func() {
session.sendMu.Lock()
session.Closed.Store(true) session.Closed.Store(true)
if session.Send != nil { close(session.Send) }
session.sendMu.Unlock()
remaining := s.manager.Unregister(session) remaining := s.manager.Unregister(session)
close(session.Send)
if session.Conn != nil { if session.Conn != nil {
_ = session.Conn.Close() _ = session.Conn.Close()
} }
@@ -586,8 +612,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
return return
} }
for _, session := range targets { for _, session := range targets {
if filter != nil && !filter(session) { if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) { continue }
if filter != nil && !filter(session) {
continue continue
} }
if session.enqueue(payload) { if session.enqueue(payload) {
@@ -603,12 +630,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
} }
} }
func (s *wsService) IsGuestOnline(guestID string) bool { func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool {
guestID = strings.TrimSpace(guestID) if customerID <= 0 || channelID <= 0 { return false }
if guestID == "" { return s.manager.HasTopic(s.customerTopic(customerID, channelID))
return false
}
return s.manager.HasTopic(s.guestTopic(guestID))
} }
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string { 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)} topics := []string{s.conversationTopic(conversation.ID)}
if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" { if conversation.CustomerID > 0 && conversation.ChannelID > 0 {
topics = append(topics, s.guestTopic(identity.ExternalID)) topics = append(topics, s.customerTopic(conversation.CustomerID, conversation.ChannelID))
} }
if conversation.CurrentAssigneeID > 0 { if conversation.CurrentAssigneeID > 0 {
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID)) 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)} return []string{s.notificationTopic(session.Principal.UserID)}
case realtimeRoleAdmin: case realtimeRoleAdmin:
if session.Principal == nil || session.Principal.UserID <= 0 { if session.Principal == nil || session.Principal.UserID <= 0 || !slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) {
return []string{realtimeTopicAdminAll} return nil
} }
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll} return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
default: default:
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。 if session.CustomerSession != nil && session.External != nil && session.CustomerSession.CustomerID > 0 && session.CustomerSession.ChannelID > 0 {
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" { return []string{s.customerTopic(session.CustomerSession.CustomerID, session.CustomerSession.ChannelID)}
return []string{s.guestTopic(session.External.ExternalID)} }
} return nil
if session.Principal != nil && session.Principal.UserID > 0 {
return []string{s.userTopic(session.Principal.UserID)}
}
return nil
} }
} }
@@ -693,7 +713,7 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
} }
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool { func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
if session == nil || conversationID <= 0 { if session == nil || conversationID <= 0 || session.Role == realtimeRoleNotification {
return false return false
} }
if session.Role == realtimeRoleAdmin { 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) return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
} }
func (s *wsService) userTopic(userID int64) string { func (s *wsService) customerTopic(customerID, channelID int64) string {
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10) return realtimeTopicCustomerPrefix + strconv.FormatInt(channelID, 10) + ":" + strconv.FormatInt(customerID, 10)
}
func (s *wsService) guestTopic(guestID string) string {
return realtimeTopicGuestPrefix + strings.TrimSpace(guestID)
} }
func (s *wsService) adminTopic(userID int64) string { func (s *wsService) adminTopic(userID int64) string {
@@ -817,3 +833,24 @@ func formatWsTime(t *time.Time) string {
} }
return t.Format(time.DateTime) 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
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services package services
import ( import (
@@ -6,7 +8,6 @@ import (
"time" "time"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
) )
@@ -49,12 +50,9 @@ func TestWsAgentActivityEventType(t *testing.T) {
} }
func TestPublishAgentActivity(t *testing.T) { func TestPublishAgentActivity(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService() ws := newWsService()
session := &ClientSession{ session := newWsAuthClient(f, "test-sess", realtimeRoleAdmin)
ID: "test-sess",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
ws.manager.Register(session, []string{"conversation:100"}) ws.manager.Register(session, []string{"conversation:100"})
conv := &models.Conversation{ID: 100} conv := &models.Conversation{ID: 100}
@@ -100,27 +98,16 @@ func TestWsOperatorPresenceEventType(t *testing.T) {
} }
func TestPublishOperatorPresence(t *testing.T) { func TestPublishOperatorPresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService() ws := newWsService()
subscriber := &ClientSession{ subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
ws.manager.Register(subscriber, []string{"conversation:100"}) ws.manager.Register(subscriber, []string{"conversation:100"})
sender := &ClientSession{ sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
ID: "sender-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
ws.manager.Register(sender, []string{"conversation:100"}) ws.manager.Register(sender, []string{"conversation:100"})
principal := &dto.AuthPrincipal{ principal := f.principal
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
ws.PublishOperatorPresence(100, principal, "typing", sender.ID) ws.PublishOperatorPresence(100, principal, "typing", sender.ID)
@@ -136,7 +123,7 @@ func TestPublishOperatorPresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence { if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) 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) t.Fatalf("unexpected data: %+v", event.Data)
} }
case <-time.After(time.Second): case <-time.After(time.Second):
@@ -151,26 +138,13 @@ func TestPublishOperatorPresence(t *testing.T) {
} }
func TestHandleClientMessagePresence(t *testing.T) { func TestHandleClientMessagePresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService() ws := newWsService()
subscriber := &ClientSession{ subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
ws.manager.Register(subscriber, []string{"conversation:100"}) ws.manager.Register(subscriber, []string{"conversation:100"})
principal := &dto.AuthPrincipal{ sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
sender := &ClientSession{
ID: "sender-conn",
Principal: principal,
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
ws.manager.Register(sender, []string{"conversation:100"}) ws.manager.Register(sender, []string{"conversation:100"})
msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`) msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`)
@@ -188,7 +162,7 @@ func TestHandleClientMessagePresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence { if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type) 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) t.Fatalf("unexpected data: %+v", event.Data)
} }
case <-time.After(time.Second): case <-time.After(time.Second):

View File

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

View File

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

View File

@@ -1,37 +1,31 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client" "use client"
import { import {
AlertTriangleIcon, AlertTriangleIcon,
ArrowRightIcon, ArrowRightIcon,
CheckCircle2Icon, CheckCircle2Icon,
Loader2Icon,
SparklesIcon, SparklesIcon,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { import {
notifySetupReadinessUpdated,
SETUP_READINESS_UPDATED_EVENT, SETUP_READINESS_UPDATED_EVENT,
} from "@/components/operator-readiness-indicator" } from "@/components/operator-readiness-indicator"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { import {
applySetupQuickFix,
fetchSetupReadiness, fetchSetupReadiness,
type SetupReadinessResponse, type SetupReadinessResponse,
} from "@/lib/api/setup-readiness" } from "@/lib/api/setup-readiness"
export function SetupReadinessBanner({ export function SetupReadinessBanner({ onUpdated }: {
onUpdated,
}: {
onUpdated?: () => void onUpdated?: () => void
}) { }) {
const t = useI18n() const t = useI18n()
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null) const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [fixing, setFixing] = useState(false)
const loadReadiness = useCallback(async () => { const loadReadiness = useCallback(async () => {
try { try {
@@ -46,29 +40,13 @@ export function SetupReadinessBanner({
void loadReadiness() void loadReadiness()
const handleUpdated = () => { const handleUpdated = () => {
void loadReadiness() void loadReadiness()
onUpdated?.()
} }
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
return () => { return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
} }
}, [loadReadiness]) }, [loadReadiness, onUpdated])
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)
}
}
if (!readiness) { if (!readiness) {
return null return null
@@ -137,24 +115,12 @@ export function SetupReadinessBanner({
<div className="flex shrink-0 flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2">
{canQuickSetupAll ? ( {canQuickSetupAll ? (
<Button <Button size="sm" className="gap-1.5 rounded-md" render={<Link href="/dashboard/setup" />}>
size="sm" <SparklesIcon className="size-4" />
className="gap-1.5 rounded-md" <span>{t("setupWizard.preview.review")}</span>
disabled={fixing}
onClick={() => void handleQuickSetupAll()}
>
{fixing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SparklesIcon className="size-4" />
)}
<span>
{fixing
? t("setupWizard.quickSetupRunning")
: t("setupWizard.quickSetupAll")}
</span>
</Button> </Button>
) : null} ) : null}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: retain operator drafts and forward pending-send state.
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
@@ -11,11 +13,14 @@ import {
sendOperatorPresence, sendOperatorPresence,
useAgentConversationsStore, useAgentConversationsStore,
} from "@/hooks/use-agent-conversation-realtime" } from "@/hooks/use-agent-conversation-realtime"
import type { MessageEditorDraftStore } from "@/lib/message-editor-drafts"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin" import { fetchQuickReplyListAll, type AdminQuickReply } from "@/lib/api/admin"
import { suggestAgentReply } from "@/lib/api/agent" import { suggestAgentReply } from "@/lib/api/agent"
type AgentMessageEditorProps = { type AgentMessageEditorProps = {
draftStore: MessageEditorDraftStore
sending?: boolean
disabled?: boolean disabled?: boolean
uploadingAsset?: boolean uploadingAsset?: boolean
onSend: (html: string) => Promise<void> onSend: (html: string) => Promise<void>
@@ -28,6 +33,8 @@ type AgentMessageEditorProps = {
* Supports quick replies picker and slash command (/) autocomplete. * Supports quick replies picker and slash command (/) autocomplete.
*/ */
export function AgentMessageEditor({ export function AgentMessageEditor({
draftStore,
sending = false,
disabled = false, disabled = false,
uploadingAsset = false, uploadingAsset = false,
onSend, onSend,
@@ -79,6 +86,8 @@ export function AgentMessageEditor({
<SharedMessageEditor <SharedMessageEditor
variant="agent" variant="agent"
conversationId={selectedConversationId} conversationId={selectedConversationId}
draftStore={draftStore}
sending={sending}
disabled={disabled} disabled={disabled}
uploadingAsset={uploadingAsset} uploadingAsset={uploadingAsset}
quickReplies={{ quickReplies={{

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: capture operator recipients and preserve failed message drafts.
"use client"; "use client";
import { import {
@@ -61,6 +63,7 @@ import {
type AIWorkflowRun, type AIWorkflowRun,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { readSession } from "@/lib/auth"; import { readSession } from "@/lib/auth";
import { createMessageEditorDraftStore } from "@/lib/message-editor-drafts";
import { renderIMMessageHTML } from "@/lib/im-message"; import { renderIMMessageHTML } from "@/lib/im-message";
import { import {
agentConversationSelectors, agentConversationSelectors,
@@ -177,6 +180,8 @@ export function ChatPanel() {
null, null,
); );
const shouldStickToBottomRef = useRef(true); const shouldStickToBottomRef = useRef(true);
const [draftStore] = useState(createMessageEditorDraftStore);
useEffect(() => () => draftStore.clear(), [draftStore]);
const [claiming, setClaiming] = useState(false); const [claiming, setClaiming] = useState(false);
const [claimDialogOpen, setClaimDialogOpen] = useState(false); const [claimDialogOpen, setClaimDialogOpen] = useState(false);
const [transferDialogOpen, setTransferDialogOpen] = useState(false); const [transferDialogOpen, setTransferDialogOpen] = useState(false);
@@ -314,12 +319,19 @@ export function ChatPanel() {
}; };
const handleSend = async (html: string) => { const handleSend = async (html: string) => {
if (!conversation || sending || isClosedConversation) return; // Bind the displayed draft to its recipient before the store reads selection.
const recipientId = conversation?.id;
try { try {
if (!recipientId || sending || isClosedConversation ||
useAgentConversationsStore.getState().selectedConversationId !== recipientId) {
throw new Error(t("conversation.sendMessageFailed"));
}
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
await sendMessage(html); const message = await sendMessage(html, recipientId);
if (!message) throw new Error(t("conversation.sendMessageFailed"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed")); toast.error(error instanceof Error ? error.message : t("conversation.sendMessageFailed"));
throw error;
} }
}; };
@@ -495,20 +507,28 @@ export function ChatPanel() {
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<AgentMessageEditor <AgentMessageEditor
disabled={!conversation || sending} draftStore={draftStore}
disabled={!conversation}
sending={sending}
uploadingAsset={uploadingAsset} uploadingAsset={uploadingAsset}
onSend={handleSend} onSend={handleSend}
onUploadImage={async (file) => { onUploadImage={async (file) => {
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
const uploaded = await uploadImage(file); try {
return uploaded; return await uploadImage(file, conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("upload.imageUploadFailed"));
throw error;
}
}} }}
onSendAttachment={async (file) => { onSendAttachment={async (file) => {
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
try { try {
await sendAttachment(file); const message = await sendAttachment(file, conversation.id);
if (!message) throw new Error(t("conversation.sendAttachmentFailed"));
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed")); toast.error(error instanceof Error ? error.message : t("conversation.sendAttachmentFailed"));
throw error;
} }
}} }}
/> />

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client" "use client"
import { import {
@@ -14,7 +15,7 @@ import {
XCircleIcon, XCircleIcon,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { DashboardPage } from "@/components/dashboard-page" import { DashboardPage } from "@/components/dashboard-page"
@@ -25,6 +26,7 @@ import {
import { OptionCombobox } from "@/components/option-combobox" import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { import {
Table, Table,
TableBody, TableBody,
@@ -39,6 +41,7 @@ import {
fetchSetupReadiness, fetchSetupReadiness,
simulateSetupRoute, simulateSetupRoute,
type SetupQuickFixAction, type SetupQuickFixAction,
type SetupQuickFixResponse,
type SetupReadinessResponse, type SetupReadinessResponse,
type SetupSimulateRouteResponse, type SetupSimulateRouteResponse,
type SetupStepStatus, type SetupStepStatus,
@@ -98,6 +101,15 @@ export function SetupWizardView() {
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null) const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [fixingAction, setFixingAction] = useState<string | null>(null) const [fixingAction, setFixingAction] = useState<string | null>(null)
const fixingRef = useRef(false)
const selectedChannelRef = useRef("")
const simulationRequestRef = useRef(0)
const readinessRequestRef = useRef(0)
const [pendingFix, setPendingFix] = useState<{
action: SetupQuickFixAction
entityId?: number
preview: SetupQuickFixResponse
} | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>("") const [selectedChannelId, setSelectedChannelId] = useState<string>("")
const [simulation, setSimulation] = const [simulation, setSimulation] =
@@ -105,31 +117,38 @@ export function SetupWizardView() {
const [simulating, setSimulating] = useState(false) const [simulating, setSimulating] = useState(false)
const runSimulation = useCallback(async (channelIdNum?: number) => { const runSimulation = useCallback(async (channelIdNum?: number) => {
const requestId = ++simulationRequestRef.current
setSimulating(true) setSimulating(true)
setSimulation(null)
try { try {
const sim = await simulateSetupRoute({ const sim = await simulateSetupRoute({
channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined, channel_id: channelIdNum && channelIdNum > 0 ? channelIdNum : undefined,
bypass_ai: true, bypass_ai: false,
}) })
setSimulation(sim) if (requestId !== simulationRequestRef.current) return
setSimulation({ ...sim, candidate_operators: sim.candidate_operators ?? [] })
if (sim.channel_id > 0) { if (sim.channel_id > 0) {
selectedChannelRef.current = String(sim.channel_id)
setSelectedChannelId(String(sim.channel_id)) setSelectedChannelId(String(sim.channel_id))
} }
} catch (error) { } catch (error) {
if (requestId !== simulationRequestRef.current) return
toast.error( toast.error(
error instanceof Error ? error.message : t("common.operationFailed"), error instanceof Error ? error.message : t("common.operationFailed"),
) )
} finally { } finally {
setSimulating(false) if (requestId === simulationRequestRef.current) setSimulating(false)
} }
}, [t]) }, [t])
const loadAll = useCallback(async () => { const loadAll = useCallback(async () => {
const requestId = ++readinessRequestRef.current
setLoading(true) setLoading(true)
try { try {
const data = await fetchSetupReadiness() const data = await fetchSetupReadiness()
if (requestId !== readinessRequestRef.current) return
setReadiness(data) setReadiness(data)
const currentNum = Number(selectedChannelId) const currentNum = Number(selectedChannelRef.current)
const targetChannelId = const targetChannelId =
currentNum > 0 currentNum > 0
? currentNum ? currentNum
@@ -141,13 +160,14 @@ export function SetupWizardView() {
} }
await runSimulation(targetChannelId) await runSimulation(targetChannelId)
} catch (error) { } catch (error) {
if (requestId !== readinessRequestRef.current) return
toast.error( toast.error(
error instanceof Error ? error.message : t("common.loadFailed"), error instanceof Error ? error.message : t("common.loadFailed"),
) )
} finally { } finally {
setLoading(false) if (requestId === readinessRequestRef.current) setLoading(false)
} }
}, [runSimulation, selectedChannelId, t]) }, [runSimulation, t])
useEffect(() => { useEffect(() => {
void loadAll() void loadAll()
@@ -157,30 +177,52 @@ export function SetupWizardView() {
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
return () => { return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
simulationRequestRef.current++
readinessRequestRef.current++
} }
}, [loadAll]) }, [loadAll])
async function handleQuickFix( async function handleQuickFix(action: SetupQuickFixAction, entityId?: number) {
action: SetupQuickFixAction, if (fixingRef.current) return
entityId?: number, fixingRef.current = true
) { setFixingAction(entityId ? `${action}:${entityId}` : action)
const key = entityId ? `${action}:${entityId}` : action
setFixingAction(key)
try { try {
const res = await applySetupQuickFix({ action, entity_id: entityId }) const preview = await applySetupQuickFix({ action, entity_id: entityId })
setReadiness(res.readiness) setPendingFix({ action, entityId, preview })
notifySetupReadinessUpdated()
toast.success(t("setupWizard.quickFixSuccess"))
const targetChannelId =
Number(selectedChannelId) ||
res.readiness.channels.find((ch) => ch.status === 0)?.id ||
0
await runSimulation(targetChannelId)
} catch (error) { } catch (error) {
toast.error( toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
error instanceof Error ? error.message : t("common.operationFailed"),
)
} finally { } finally {
fixingRef.current = false
setFixingAction(null)
}
}
async function confirmQuickFix() {
if (!pendingFix || fixingRef.current) return
fixingRef.current = true
setFixingAction(pendingFix.action)
try {
const result = await applySetupQuickFix({
action: pendingFix.action,
entity_id: pendingFix.entityId,
apply: true,
preview_token: pendingFix.preview.preview_token,
})
if (!result.applied) throw new Error(t("setupWizard.preview.notApplied"))
if (result.readiness) setReadiness(result.readiness)
setPendingFix(null)
notifySetupReadinessUpdated()
if (result.warning_codes.length) {
toast.warning(result.warning_codes.map((code) => t(`setupWizard.repairWarnings.${code}`)).join(" "))
} else {
toast.success(t("setupWizard.quickFixSuccess"))
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.operationFailed"))
// A failed/stale preview must be reviewed again, not silently reapplied.
setPendingFix(null)
} finally {
fixingRef.current = false
setFixingAction(null) setFixingAction(null)
} }
} }
@@ -418,7 +460,10 @@ export function SetupWizardView() {
</h2> </h2>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{readiness.steps.map((step) => { {readiness.steps.map((step) => {
const primaryQuickFix = step.quick_fix_actions[0] const primaryQuickFix =
(step.key === "ai_agents" || step.key === "channels") && step.total_count > 0
? undefined
: step.quick_fix_actions[0]
return ( return (
<div <div
key={step.key} key={step.key}
@@ -469,6 +514,11 @@ export function SetupWizardView() {
{t(`setupWizard.issues.${issue.code}`)} {t(`setupWizard.issues.${issue.code}`)}
</span> </span>
</div> </div>
{issue.quick_fix_action && issue.entity_id > 0 && issue.entity_type !== "operator" ? (
<Button size="sm" variant="outline" className="shrink-0 rounded-md" disabled={fixingAction !== null} onClick={() => void handleQuickFix(issue.quick_fix_action!, issue.entity_id)}>
{t("setupWizard.preview.review")}
</Button>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -542,6 +592,7 @@ export function SetupWizardView() {
options={channelOptions} options={channelOptions}
value={selectedChannelId} value={selectedChannelId}
onChange={(val) => { onChange={(val) => {
selectedChannelRef.current = val
setSelectedChannelId(val) setSelectedChannelId(val)
void runSimulation(Number(val)) void runSimulation(Number(val))
}} }}
@@ -744,6 +795,39 @@ export function SetupWizardView() {
</div> </div>
) : null} ) : null}
</div> </div>
<Dialog open={pendingFix !== null} onOpenChange={(open) => { if (!open && !fixingRef.current) setPendingFix(null) }}>
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] rounded-md sm:max-w-2xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t("setupWizard.preview.title")}</DialogTitle>
<DialogDescription>{t("setupWizard.preview.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3 overflow-y-auto break-words">
{pendingFix?.preview.changes.length === 0 ? <p>{t("setupWizard.preview.noChanges")}</p> : null}
{pendingFix?.preview.changes.map((change, index) => (
<div key={`${change.entity_type}-${change.entity_id}-${index}`} className="space-y-2 rounded-md border p-3">
<p className="font-medium">{t(`setupWizard.preview.${change.operation}`)}: {change.entity_name}{change.entity_id > 0 ? ` (#${change.entity_id})` : ""}</p>
<dl className="space-y-1 text-xs">
{Object.entries(change.after).map(([field, value]) => (
<div key={field} className="grid gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<dt className="text-muted-foreground">{t(`setupWizard.repairFields.${field}`)}</dt>
<dd>{change.before && field in change.before ? `${String(change.before[field])} → ` : ""}{String(value)}</dd>
</div>
))}
</dl>
</div>
))}
{pendingFix?.preview.warning_codes.map((code) => <p key={code} className="text-sm text-amber-700 dark:text-amber-300">{t(`setupWizard.repairWarnings.${code}`)}</p>)}
<Link className="text-sm underline" href="/dashboard/agent-team-schedules">{t("setupWizard.preview.manageSchedules")}</Link>
</div>
<DialogFooter>
<Button variant="outline" disabled={fixingAction !== null} onClick={() => setPendingFix(null)}>{t("common.cancel")}</Button>
<Button disabled={fixingAction !== null || !pendingFix?.preview.changes.length} onClick={() => void confirmQuickFix()}>
{fixingAction !== null ? <Loader2Icon className="size-4 animate-spin" /> : null}
{t("setupWizard.preview.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardPage> </DashboardPage>
) )
} }

View File

@@ -1,8 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client" "use client"
import { CheckCircle2Icon } from "lucide-react" import { MessageSquareIcon } from "lucide-react"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { Button } from "@/components/ui/button"
import { getStandaloneChatUrl } from "@/components/support-chat/close-navigation"
export default function Page() { export default function Page() {
const t = useI18n() const t = useI18n()
@@ -10,15 +13,18 @@ export default function Page() {
return ( return (
<main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground"> <main className="flex min-h-svh items-center justify-center bg-muted px-6 text-foreground">
<section className="grid max-w-sm justify-items-center gap-4 text-center"> <section className="grid max-w-sm justify-items-center gap-4 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900"> <div className="flex size-14 items-center justify-center rounded-md border bg-card text-muted-foreground">
<CheckCircle2Icon className="size-7" /> <MessageSquareIcon className="size-7" aria-hidden="true" />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1> <h1 className="text-lg font-semibold text-foreground">{t("supportChat.dismissedTitle")}</h1>
<p className="text-sm leading-6 text-muted-foreground"> <p className="text-sm leading-6 text-muted-foreground">
{t("supportChat.closedDescription")} {t("supportChat.dismissedDescription")}
</p> </p>
</div> </div>
<Button onClick={() => window.location.replace(getStandaloneChatUrl(window.location.search))}>
{t("supportChat.returnToChat")}
</Button>
</section> </section>
</main> </main>
) )

View File

@@ -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" "use client"
import { import {
@@ -11,7 +13,7 @@ import {
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { SessionProvider, useSession } from "@/components/session-provider" import { SessionProvider, useSession } from "@/components/session-provider"
import { type AuthSession } from "@/lib/auth" import { readSession, type AuthSession } from "@/lib/auth"
type AuthContextValue = { type AuthContextValue = {
session: AuthSession | null session: AuthSession | null
@@ -46,17 +48,23 @@ function DashboardAuthProvider({ children }: { children: ReactNode }) {
try { try {
await endSession() await endSession()
} finally { } finally {
startTransition(() => { // A different tab or login flow may have established a new session meanwhile.
router.replace("/dashboard/login") if (!readSession()) {
}) startTransition(() => {
router.replace("/dashboard/login")
})
}
} }
} }
useEffect(() => { useEffect(() => {
if (ready && !session && requiresAuth) { if (ready && !session && requiresAuth) {
startTransition(() => { // A different tab or login flow may have established a new session meanwhile.
router.replace("/dashboard/login") if (!readSession()) {
}) startTransition(() => {
router.replace("/dashboard/login")
})
}
} }
}, [ready, requiresAuth, router, session]) }, [ready, requiresAuth, router, session])

View File

@@ -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" "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 Placeholder from "@tiptap/extension-placeholder"
import { EditorContent, useEditor } from "@tiptap/react" import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit" import StarterKit from "@tiptap/starter-kit"
@@ -32,10 +34,14 @@ import {
MessageImageExtension, MessageImageExtension,
removeEditorImageByTitle, removeEditorImageByTitle,
revokeEditorObjectUrl, revokeEditorObjectUrl,
revokeEditorObjectUrls,
setEditorImageUploadingByTitle, setEditorImageUploadingByTitle,
type UploadedEditorImage, type UploadedEditorImage,
} from "@/lib/im-editor-image" } from "@/lib/im-editor-image"
import {
createMessageEditorDraftStore,
type MessageEditorDraft,
type MessageEditorDraftStore,
} from "@/lib/message-editor-drafts"
import { generateUUID } from "@/lib/utils" import { generateUUID } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
@@ -60,6 +66,9 @@ export type SharedMessageEditorAICopilot = {
export type SharedMessageEditorProps = { export type SharedMessageEditorProps = {
variant: SharedMessageEditorVariant variant: SharedMessageEditorVariant
conversationId?: number | null conversationId?: number | null
draftScope?: string
draftStore?: MessageEditorDraftStore
sending?: boolean
disabled?: boolean disabled?: boolean
uploadingAsset?: boolean uploadingAsset?: boolean
manageLocalUploading?: boolean manageLocalUploading?: boolean
@@ -79,6 +88,9 @@ export type SharedMessageEditorProps = {
export function SharedMessageEditor({ export function SharedMessageEditor({
variant, variant,
conversationId, conversationId,
draftScope = "default",
draftStore,
sending = false,
disabled = false, disabled = false,
uploadingAsset = false, uploadingAsset = false,
manageLocalUploading = false, manageLocalUploading = false,
@@ -91,6 +103,18 @@ export function SharedMessageEditor({
}: SharedMessageEditorProps) { }: SharedMessageEditorProps) {
const t = useI18n() const t = useI18n()
const [localUploading, setLocalUploading] = useState(false) 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 [quickReplySearch, setQuickReplySearch] = useState("")
const [suggestedReply, setSuggestedReply] = useState<string | null>(null) const [suggestedReply, setSuggestedReply] = useState<string | null>(null)
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false) const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false)
@@ -105,11 +129,10 @@ export function SharedMessageEditor({
const onSendAttachmentRef = useRef(onSendAttachment) const onSendAttachmentRef = useRef(onSendAttachment)
const onTypingRef = useRef(onTyping) const onTypingRef = useRef(onTyping)
const shouldRestoreFocusRef = useRef(false) const shouldRestoreFocusRef = useRef(false)
const objectUrlsRef = useRef<Set<string>>(new Set())
const uploadedImagesRef = useRef(new Map<string, UploadedMessageEditorImage>())
const placeholderRef = useRef(t("conversation.editorPlaceholder")) const placeholderRef = useRef(t("conversation.editorPlaceholder"))
const isCustomer = variant === "customer" const isCustomer = variant === "customer"
const isUploading = uploadingAsset || (manageLocalUploading && localUploading) const isUploading = uploadingAsset || (manageLocalUploading && localUploading)
const isSending = sending || draftSending
placeholderRef.current = t("conversation.editorPlaceholder") placeholderRef.current = t("conversation.editorPlaceholder")
@@ -118,31 +141,21 @@ export function SharedMessageEditor({
}, [quickReplies]) }, [quickReplies])
useEffect(() => { useEffect(() => {
const objectUrls = objectUrlsRef.current mountedRef.current = true
return () => { 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 onSendRef.current = onSend
}, [onSend])
useEffect(() => {
onUploadImageRef.current = onUploadImage onUploadImageRef.current = onUploadImage
}, [onUploadImage])
useEffect(() => {
onSendAttachmentRef.current = onSendAttachment onSendAttachmentRef.current = onSendAttachment
}, [onSendAttachment])
useEffect(() => {
onTypingRef.current = onTyping onTypingRef.current = onTyping
}, [onTyping]) }, [onSend, onUploadImage, onSendAttachment, onTyping])
useEffect(() => {
setSuggestedReply(null)
}, [conversationId])
const editor = useEditor({ const editor = useEditor({
immediatelyRender: false, immediatelyRender: false,
@@ -162,6 +175,8 @@ export function SharedMessageEditor({
], ],
content: "", content: "",
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
const displayed = displayedDraftRef.current
if (displayed) drafts.update(displayed.draft, editor.getHTML())
if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) { if (!editor.isEmpty && isMeaningfulHTML(editor.getHTML())) {
onTypingRef.current?.() onTypingRef.current?.()
} }
@@ -227,7 +242,7 @@ export function SharedMessageEditor({
return false return false
}, },
handlePaste: (_view, event) => { handlePaste: (_view, event) => {
if (disabled || isUploading) { if (disabled || isUploading || isSending) {
return false return false
} }
const imageFile = getClipboardImageFile(event.clipboardData) 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(() => { useEffect(() => {
if (!editor) { if (!editor) {
return return
@@ -252,42 +310,45 @@ export function SharedMessageEditor({
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) { if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
return return
} }
requestAnimationFrame(() => { const draft = displayedDraftRef.current?.draft
editor.commands.focus() const frame = requestAnimationFrame(() => {
if (!editor.isDestroyed && displayedDraftRef.current?.draft === draft) editor.commands.focus()
}) })
return () => cancelAnimationFrame(frame)
}, [disabled, editor, isUploading]) }, [disabled, editor, isUploading])
async function handleSend() { async function handleSend() {
if (!editor || disabled || isUploading) { const displayed = displayedDraftRef.current
return if (!editor || !displayed || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) return
} if (displayed.key !== contextRef.current.draftKey) return
const rawHTML = editor.getHTML() const rawHTML = editor.getHTML()
if (hasUploadingEditorImages(rawHTML, uploadedImagesRef.current)) { if (hasUploadingEditorImages(rawHTML, displayed.draft.uploadedImages)) return
return const html = buildSendableEditorHTML(rawHTML, displayed.draft.uploadedImages)
} if (!isMeaningfulHTML(html)) return
const html = buildSendableEditorHTML(rawHTML, uploadedImagesRef.current)
if (!isMeaningfulHTML(html)) { const sent = drafts.beginSend(displayed.key)
return if (!sent) return
} const send = onSendRef.current
await onSendRef.current(html) try {
editor.commands.clearContent(true) await send(html)
revokeEditorObjectUrls(objectUrlsRef.current) const cleared = drafts.finishSend(sent, true)
uploadedImagesRef.current.clear() if (!cleared || !mountedRef.current || editor.isDestroyed || displayedDraftRef.current?.draft !== sent.draft) return
setQuickReplySearch("") editor.commands.setContent("", { emitUpdate: false })
isSlashTriggeredRef.current = false setQuickReplySearch("")
quickRepliesRef.current?.onOpenChange(false) isSlashTriggeredRef.current = false
setSuggestedReply(null) quickRepliesRef.current?.onOpenChange(false)
if (!isCustomer) { setSuggestedReply(null)
requestAnimationFrame(() => { if (!isCustomer) focusEditorEnd()
editor.commands.focus("end") } catch {
}) // The caller displays the error. Keep the entire draft, including images, for retry.
drafts.finishSend(sent, false)
} }
} }
async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) { async function handleSelectImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] const file = event.target.files?.[0]
event.target.value = "" event.target.value = ""
if (!file || !editor || disabled || isUploading) { if (!file || !editor || disabled || isUploading || isSending) {
restoreFocusIfNeeded() restoreFocusIfNeeded()
return return
} }
@@ -295,68 +356,86 @@ export function SharedMessageEditor({
} }
async function insertUploadedImage(file: File) { async function insertUploadedImage(file: File) {
if (!editor || disabled || isUploading) { const displayed = displayedDraftRef.current
return 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 shouldRestoreFocusRef.current = true
uploadingRef.current = true
const objectUrl = URL.createObjectURL(file) const objectUrl = URL.createObjectURL(file)
objectUrlsRef.current.add(objectUrl) draft.objectUrls.add(objectUrl)
const placeholderId = `uploading-${generateUUID()}` const placeholderId = `uploading-${generateUUID()}`
editor editor.chain().focus().setImage({
.chain() src: objectUrl,
.focus() alt: file.name || "uploading-image",
.setImage({ title: placeholderId,
src: objectUrl, }).run()
alt: file.name || "uploading-image",
title: placeholderId,
})
.run()
setEditorImageUploadingByTitle(editor, placeholderId) 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 { try {
setLocalUploading(true) 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) { if (!uploaded?.assetId || !uploaded.provider || !uploaded.storageKey) {
removeEditorImageByTitle(editor, placeholderId) removePlaceholder()
revokeEditorObjectUrl(objectUrlsRef.current, objectUrl)
return return
} }
markEditorImageUploadedByTitle( draft.uploadedImages.set(placeholderId, uploaded)
editor, drafts.notify(draft)
placeholderId, } catch {
uploaded, removePlaceholder()
uploadedImagesRef.current
)
} finally { } finally {
setLocalUploading(false) if (generation === mutationGenerationRef.current) {
requestAnimationFrame(() => { uploadingRef.current = false
if (!disabled && shouldRestoreFocusRef.current) { if (mountedRef.current) setLocalUploading(false)
editor.commands.focus() }
} if (isVisible()) restoreFocusIfNeeded()
})
} }
} }
async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) { async function handleSelectAttachment(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] const file = event.target.files?.[0]
event.target.value = "" event.target.value = ""
if (!file || disabled || isUploading) { if (!file || disabled || isUploading || isSending || drafts.isSending() || uploadingRef.current) {
restoreFocusIfNeeded() restoreFocusIfNeeded()
return return
} }
const draft = displayedDraftRef.current?.draft
const sendAttachment = onSendAttachmentRef.current
const generation = mutationGenerationRef.current
shouldRestoreFocusRef.current = editor?.isFocused ?? true shouldRestoreFocusRef.current = editor?.isFocused ?? true
uploadingRef.current = true
try { try {
setLocalUploading(true) setLocalUploading(true)
await onSendAttachmentRef.current(file) await sendAttachment(file)
} catch {
// The caller reports failed attachment sends; no text draft is discarded.
} finally { } finally {
setLocalUploading(false) if (generation === mutationGenerationRef.current) {
requestAnimationFrame(() => { uploadingRef.current = false
if (editor && !disabled && shouldRestoreFocusRef.current) { if (mountedRef.current) setLocalUploading(false)
editor.commands.focus() }
} if (displayedDraftRef.current?.draft === draft) restoreFocusIfNeeded()
})
} }
} }
@@ -396,15 +475,23 @@ export function SharedMessageEditor({
quickRepliesRef.current?.onOpenChange(false) quickRepliesRef.current?.onOpenChange(false)
setQuickReplySearch("") setQuickReplySearch("")
isSlashTriggeredRef.current = false isSlashTriggeredRef.current = false
focusEditorEnd()
}
function focusEditorEnd() {
const draft = displayedDraftRef.current?.draft
requestAnimationFrame(() => { requestAnimationFrame(() => {
editor.commands.focus("end") if (editor && mountedRef.current && !editor.isDestroyed && displayedDraftRef.current?.draft === draft) {
editor.commands.focus("end")
}
}) })
} }
function restoreFocusIfNeeded() { function restoreFocusIfNeeded() {
if (editor && shouldRestoreFocusRef.current) { if (editor && shouldRestoreFocusRef.current) {
const draft = displayedDraftRef.current?.draft
requestAnimationFrame(() => { 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) const isGenerating = isGeneratingSuggestion || Boolean(aiCopilot?.loading)
async function handleTriggerSuggest() { async function handleTriggerSuggest() {
if (!aiCopilot || disabled || isUploading || isGenerating) { if (!aiCopilot || disabled || isUploading || isGenerating || suggestionPendingRef.current) return
return const requestId = ++suggestionRequestRef.current
} suggestionPendingRef.current = true
const context = contextRef.current
setIsGeneratingSuggestion(true) setIsGeneratingSuggestion(true)
try { try {
const text = await aiCopilot.onSuggest() 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()) setSuggestedReply(text.trim())
} }
} catch {
// The caller displays the suggestion error. Ignore any late failure here.
} finally { } 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() editor.chain().focus().insertContent(suggestedReply).run()
setSuggestedReply(null) setSuggestedReply(null)
requestAnimationFrame(() => { focusEditorEnd()
editor.commands.focus("end")
})
} }
function handleDismissSuggestedReply() { function handleDismissSuggestedReply() {
@@ -520,7 +611,7 @@ export function SharedMessageEditor({
shouldRestoreFocusRef.current = editor?.isFocused ?? true shouldRestoreFocusRef.current = editor?.isFocused ?? true
imageInputRef.current?.click() imageInputRef.current?.click()
}} }}
disabled={disabled || isUploading} disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")} aria-label={isUploading ? t("conversation.imageUploading") : t("conversation.sendImage")}
title={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 shouldRestoreFocusRef.current = editor?.isFocused ?? true
attachmentInputRef.current?.click() attachmentInputRef.current?.click()
}} }}
disabled={disabled || isUploading} disabled={disabled || isUploading || isSending}
aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")} aria-label={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")} title={isUploading ? t("conversation.attachmentUploading") : t("conversation.sendAttachment")}
> >
@@ -640,21 +731,23 @@ export function SharedMessageEditor({
type="button" type="button"
size="icon" size="icon"
onClick={() => void handleSend()} onClick={() => void handleSend()}
disabled={disabled || isUploading} aria-busy={isSending}
disabled={disabled || isUploading || isSending}
aria-label={t("conversation.send")} aria-label={t("conversation.send")}
title={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" className="bg-primary text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_24%,transparent)] hover:bg-primary hover:brightness-105"
> >
<SendHorizonalIcon /> {isSending ? <Loader2Icon className="animate-spin" /> : <SendHorizonalIcon />}
</Button> </Button>
) : ( ) : (
<Button <Button
type="button" type="button"
size="sm" size="sm"
onClick={() => void handleSend()} onClick={() => void handleSend()}
disabled={disabled || isUploading} aria-busy={isSending}
disabled={disabled || isUploading || isSending}
> >
<SendIcon className="mr-1 size-4" /> {isSending ? <Loader2Icon className="mr-1 size-4 animate-spin" /> : <SendIcon className="mr-1 size-4" />}
{isUploading ? t("conversation.uploading") : t("conversation.send")} {isUploading ? t("conversation.uploading") : t("conversation.send")}
</Button> </Button>
)} )}

View File

@@ -1,9 +1,9 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
"use client" "use client"
import { import {
AlertTriangleIcon, AlertTriangleIcon,
CheckCircle2Icon, CheckCircle2Icon,
Loader2Icon,
RefreshCwIcon, RefreshCwIcon,
ShieldAlertIcon, ShieldAlertIcon,
SparklesIcon, SparklesIcon,
@@ -11,7 +11,6 @@ import {
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -22,9 +21,7 @@ import {
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { import {
applySetupQuickFix,
fetchSetupReadiness, fetchSetupReadiness,
type SetupQuickFixAction,
type SetupReadinessResponse, type SetupReadinessResponse,
} from "@/lib/api/setup-readiness" } from "@/lib/api/setup-readiness"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -45,7 +42,6 @@ export function OperatorReadinessIndicator({
const t = useI18n() const t = useI18n()
const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null) const [readiness, setReadiness] = useState<SetupReadinessResponse | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [fixing, setFixing] = useState(false)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const loadReadiness = useCallback(async () => { const loadReadiness = useCallback(async () => {
@@ -66,30 +62,14 @@ export function OperatorReadinessIndicator({
} }
window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.addEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void loadReadiness() if (document.visibilityState === "visible") void loadReadiness()
}, 45000) }, 60000)
return () => { return () => {
window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated) window.removeEventListener(SETUP_READINESS_UPDATED_EVENT, handleUpdated)
window.clearInterval(interval) window.clearInterval(interval)
} }
}, [loadReadiness]) }, [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) { if (loading && !readiness) {
return null return null
} }
@@ -225,43 +205,10 @@ export function OperatorReadinessIndicator({
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{!operatorOk && canEnableMyProfile ? ( {(!operatorOk && canEnableMyProfile) || (!chainOk && canQuickSetupAll) ? (
<Button <Button size="sm" className="w-full gap-1.5 rounded-md text-xs" render={<Link href="/dashboard/setup" />}>
size="sm" <SparklesIcon className="size-3.5" />
className="w-full gap-1.5 rounded-md text-xs" <span>{t("setupWizard.preview.review")}</span>
disabled={fixing}
onClick={() =>
void handleQuickFix(
chainOk || !canQuickSetupAll
? "enable_my_operator_profile"
: "quick_setup_all",
)
}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>
{chainOk || !canQuickSetupAll
? t("setupWizard.enableMyReceptionBtn")
: t("setupWizard.quickSetupAll")}
</span>
</Button>
) : !chainOk && canQuickSetupAll ? (
<Button
size="sm"
className="w-full gap-1.5 rounded-md text-xs"
disabled={fixing}
onClick={() => void handleQuickFix("quick_setup_all")}
>
{fixing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<SparklesIcon className="size-3.5" />
)}
<span>{t("setupWizard.quickSetupAll")}</span>
</Button> </Button>
) : null} ) : null}

View File

@@ -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" "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 { fetchProfile, logout } from "@/lib/api/auth"
import { import {
@@ -11,6 +13,8 @@ import {
writeSession, writeSession,
type AuthSession, type AuthSession,
} from "@/lib/auth" } from "@/lib/auth"
import { createSessionRequestGuard } from "@/lib/session-request-guard"
import { resetAgentConversations } from "@/lib/stores/agent-conversations"
type SessionContextValue = { type SessionContextValue = {
session: AuthSession | null session: AuthSession | null
@@ -22,21 +26,41 @@ type SessionContextValue = {
const SessionContext = createContext<SessionContextValue | null>(null) const SessionContext = createContext<SessionContextValue | null>(null)
function accountKey(session: AuthSession | null) {
return session ? `${session.user.userType}:${session.user.id}` : "anonymous"
}
export function SessionProvider({ children }: { children: ReactNode }) { export function SessionProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(null) const [session, setSession] = useState<AuthSession | null>(null)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [signingOut, setSigningOut] = useState(false) const [signingOut, setSigningOut] = useState(false)
const [requestGuard] = useState(() => createSessionRequestGuard(readSession))
const identityRef = useRef<string | null>(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 refreshSession = useCallback(async () => {
const stored = readSession() const stored = readSession()
if (!stored) { if (!stored) {
setSession(null) requestGuard.invalidate()
setReady(true) applySession(null)
return return
} }
const isCurrent = requestGuard.begin(stored)
try { try {
const profile = await fetchProfile() const profile = await fetchProfile()
if (!isCurrent()) return
const nextSession: AuthSession = { const nextSession: AuthSession = {
...stored, ...stored,
user: profile.user, user: profile.user,
@@ -45,60 +69,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
accessToken: profile.accessToken || stored.accessToken, accessToken: profile.accessToken || stored.accessToken,
expiresAt: profile.expiresAt || stored.expiresAt, expiresAt: profile.expiresAt || stored.expiresAt,
} }
applySession(nextSession)
writeSession(nextSession) writeSession(nextSession)
setSession(nextSession)
} catch (error) { } catch (error) {
if (!isCurrent()) return
const errorCode = (error as Error & { errorCode?: number }).errorCode const errorCode = (error as Error & { errorCode?: number }).errorCode
if (errorCode === 3000 || errorCode === 3002) { if (errorCode === 3000 || errorCode === 3002) {
clearSession() clearSession()
setSession(null) applySession(null)
} else { } else {
setSession(stored) applySession(stored)
} }
} finally { } finally {
setReady(true) if (isCurrent()) setReady(true)
} }
}, []) }, [applySession, requestGuard])
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
if (signingOut) return if (signOutPendingRef.current) return
signOutPendingRef.current = true
const generation = ++signOutGenerationRef.current
setSigningOut(true) setSigningOut(true)
requestGuard.invalidate()
// Start the request with the old token, then immediately remove local access.
const pending = logout()
clearSession()
applySession(null)
try { try {
await logout() await pending
} catch { } catch {
clearSession() // Local sign-out already completed; a late error cannot clear a new login.
} finally { } finally {
setSession(null) if (generation === signOutGenerationRef.current) {
setSigningOut(false) signOutPendingRef.current = false
setSigningOut(false)
}
} }
}, [signingOut]) }, [applySession, requestGuard])
useEffect(() => {
void refreshSession()
}, [refreshSession])
useEffect(() => { useEffect(() => {
const syncSession = () => { const syncSession = () => {
setSession(readSession()) requestGuard.invalidate()
setReady(true) const current = readSession()
if (current && signOutPendingRef.current) {
signOutGenerationRef.current += 1
signOutPendingRef.current = false
setSigningOut(false)
}
applySession(current)
} }
const syncOtherTab = (event: StorageEvent) => { 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_CHANGED_EVENT, syncSession)
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession) window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.addEventListener("storage", syncOtherTab) window.addEventListener("storage", syncOtherTab)
applySession(readSession())
void refreshSession()
return () => { return () => {
requestGuard.invalidate()
signOutGenerationRef.current += 1
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession) window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession) window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
window.removeEventListener("storage", syncOtherTab) window.removeEventListener("storage", syncOtherTab)
} }
}, []) }, [applySession, refreshSession, requestGuard])
return ( return (
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}> <SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
{children} <Fragment key={accountKey(session)}>{children}</Fragment>
</SessionContext.Provider> </SessionContext.Provider>
) )
} }

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client" "use client"
import { import {
@@ -113,12 +114,14 @@ function SupportChatPanelContent() {
useSupportChatSystemTheme() useSupportChatSystemTheme()
const messageListRef = useRef<SupportChatMessageListHandle | null>(null) const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const mountedRef = useRef(false)
const [isEmbedded, setIsEmbedded] = useState(false) const [isEmbedded, setIsEmbedded] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false) const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
const [isClosingConversation, setIsClosingConversation] = useState(false) const [isClosingConversation, setIsClosingConversation] = useState(false)
const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false) const [isNewRequestDialogOpen, setIsNewRequestDialogOpen] = useState(false)
const { const {
identityScope,
title, title,
subtitle, subtitle,
themeColor, themeColor,
@@ -129,6 +132,8 @@ function SupportChatPanelContent() {
loadOlderMessages, loadOlderMessages,
status, status,
error, error,
sending,
uploadingAsset,
isOpen, isOpen,
isVisible, isVisible,
setIsOpen, setIsOpen,
@@ -144,6 +149,7 @@ function SupportChatPanelContent() {
setConversationSatisfaction, setConversationSatisfaction,
} = useSupportChatStore( } = useSupportChatStore(
useShallow((state) => ({ useShallow((state) => ({
identityScope: state.identityScope,
title: state.title, title: state.title,
subtitle: state.subtitle, subtitle: state.subtitle,
themeColor: state.themeColor, themeColor: state.themeColor,
@@ -154,6 +160,8 @@ function SupportChatPanelContent() {
loadOlderMessages: state.loadOlderMessages, loadOlderMessages: state.loadOlderMessages,
status: state.status, status: state.status,
error: state.error, error: state.error,
sending: state.sending,
uploadingAsset: state.uploadingAsset,
isOpen: state.isOpen, isOpen: state.isOpen,
isVisible: state.isVisible, isVisible: state.isVisible,
setIsOpen: state.setIsOpen, setIsOpen: state.setIsOpen,
@@ -182,6 +190,7 @@ function SupportChatPanelContent() {
if (document.visibilityState !== "visible") { if (document.visibilityState !== "visible") {
return return
} }
if (useSupportChatStore.getState().conversation?.id !== conversation.id) return
void markConversationRead().catch((readError) => { void markConversationRead().catch((readError) => {
console.error("Failed to mark support chat conversation read", readError) console.error("Failed to mark support chat conversation read", readError)
}) })
@@ -190,9 +199,9 @@ function SupportChatPanelContent() {
useEffect(() => { useEffect(() => {
return bindSupportHostBridge({ return bindSupportHostBridge({
onInit: () => { onInit: () => {
if (useSupportChatStore.getState().isOpen) { // Clear a previous visitor's private state even while the host keeps
bootstrap() // this frame hidden; visibility must never race identity replacement.
} void bootstrap()
}, },
onOpen: () => { onOpen: () => {
setIsOpen(true) setIsOpen(true)
@@ -205,14 +214,16 @@ function SupportChatPanelContent() {
}, [bootstrap, setIsOpen, setIsVisible]) }, [bootstrap, setIsOpen, setIsVisible])
useEffect(() => { useEffect(() => {
bootstrap() void bootstrap()
}, [isOpen, bootstrap])
useEffect(() => {
mountedRef.current = true
return () => { return () => {
if (!isOpen) { mountedRef.current = false
disconnectSocket() disconnectSocket()
}
} }
}, [isOpen, bootstrap, disconnectSocket]) }, [disconnectSocket])
useEffect(() => { useEffect(() => {
maybeMarkConversationRead() maybeMarkConversationRead()
@@ -236,9 +247,21 @@ function SupportChatPanelContent() {
} }
}, [maybeMarkConversationRead]) }, [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) { async function handleSend(content: string) {
requireDisplayedConversation()
const submittedConversationId = conversation?.id
await handleSendMessage(content) await handleSendMessage(content)
messageListRef.current?.scrollToBottom() const current = useSupportChatStore.getState()
if (current.identityScope === identityScope && current.conversation?.id === submittedConversationId) {
messageListRef.current?.scrollToBottom()
}
} }
function handleCloseWindow() { function handleCloseWindow() {
@@ -246,7 +269,8 @@ function SupportChatPanelContent() {
if (isEmbedded) { if (isEmbedded) {
requestSupportHostMinimize() requestSupportHostMinimize()
} else { } else {
window.location.replace(getStandaloneClosedUrl()) setIsOpen(false)
window.location.replace(getStandaloneClosedUrl(window.location.search))
} }
} }
@@ -257,6 +281,7 @@ function SupportChatPanelContent() {
setIsClosingConversation(true) setIsClosingConversation(true)
try { try {
if (conversation?.id) { if (conversation?.id) {
requireDisplayedConversation()
await closeConversation() await closeConversation()
} }
setIsCloseDialogOpen(false) setIsCloseDialogOpen(false)
@@ -351,15 +376,29 @@ function SupportChatPanelContent() {
conversationId={conversation.id} conversationId={conversation.id}
customerToken={getCustomerSessionToken()} customerToken={getCustomerSessionToken()}
initialSatisfaction={conversation.satisfaction} initialSatisfaction={conversation.satisfaction}
onSubmitted={setConversationSatisfaction} onSubmitted={(satisfaction) => {
if (mountedRef.current && useSupportChatStore.getState().identityScope === identityScope) {
setConversationSatisfaction(satisfaction)
}
}}
/> />
</div> </div>
) : null} ) : null}
<CustomerMessageEditor <CustomerMessageEditor
conversationId={conversation?.id}
draftScope={identityScope}
sending={sending}
uploadingAsset={uploadingAsset}
disabled={!conversation || conversation.status === 4} disabled={!conversation || conversation.status === 4}
onSend={handleSend} onSend={handleSend}
onUploadImage={uploadMessageImage} onUploadImage={async (file) => {
onSendAttachment={sendAttachment} requireDisplayedConversation()
return uploadMessageImage(file)
}}
onSendAttachment={async (file) => {
requireDisplayedConversation()
await sendAttachment(file)
}}
/> />
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
import ts from "typescript" import ts from "typescript"
@@ -14,6 +15,7 @@ async function loadCloseNavigation() {
fileName: "close-navigation.ts", fileName: "close-navigation.ts",
}) })
const sandbox = { const sandbox = {
URLSearchParams,
exports: {}, exports: {},
module: { exports: {} }, module: { exports: {} },
} }
@@ -27,3 +29,10 @@ test("standalone close navigates to the non-bootstrapping closed page", async ()
assert.equal(getStandaloneClosedUrl(), "/support/chat/closed") 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}`)
})

View File

@@ -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 const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() { // Keep the entry context when dismissing/returning. These are always local
return SUPPORT_CHAT_CLOSED_PATH // 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)}`
} }

View File

@@ -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" "use client"
import { import {
@@ -6,6 +8,9 @@ import {
} from "@/components/chat/shared-message-editor" } from "@/components/chat/shared-message-editor"
type CustomerMessageEditorProps = { type CustomerMessageEditorProps = {
conversationId?: number | null
draftScope?: string
sending?: boolean
disabled?: boolean disabled?: boolean
uploadingAsset?: boolean uploadingAsset?: boolean
onSend: (html: string) => Promise<void> onSend: (html: string) => Promise<void>
@@ -14,6 +19,9 @@ type CustomerMessageEditorProps = {
} }
export function CustomerMessageEditor({ export function CustomerMessageEditor({
conversationId,
draftScope,
sending = false,
disabled = false, disabled = false,
uploadingAsset = false, uploadingAsset = false,
onSend, onSend,
@@ -23,6 +31,9 @@ export function CustomerMessageEditor({
return ( return (
<SharedMessageEditor <SharedMessageEditor
variant="customer" variant="customer"
conversationId={conversationId}
draftScope={draftScope}
sending={sending}
disabled={disabled} disabled={disabled}
uploadingAsset={uploadingAsset} uploadingAsset={uploadingAsset}
manageLocalUploading manageLocalUploading

View File

@@ -1,8 +1,12 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: bind realtime work to the current session and sanitize diagnostics.
"use client" "use client"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useSession } from "@/components/session-provider"
import { realtimeLogContext } from "@/lib/realtime-log-context"
import { createAdminWebSocketUrl } from "@/lib/api/admin" import { createAdminWebSocketUrl } from "@/lib/api/admin"
import { type AgentMessage } from "@/lib/api/agent" import { type AgentMessage } from "@/lib/api/agent"
import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime" import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime"
@@ -50,6 +54,8 @@ export function sendOperatorPresence(
export function useAgentConversationRealtime() { export function useAgentConversationRealtime() {
const t = useI18n() const t = useI18n()
const { session } = useSession()
const accessToken = session?.accessToken
const selectedConversationId = useAgentConversationsStore( const selectedConversationId = useAgentConversationsStore(
(state) => state.selectedConversationId (state) => state.selectedConversationId
) )
@@ -59,19 +65,23 @@ export function useAgentConversationRealtime() {
const realtimeRef = useRef<AgentRealtimeConnection | null>(null) const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
const subscribedConversationIdRef = useRef<number | null>(null) const subscribedConversationIdRef = useRef<number | null>(null)
const selectedConversationIdRef = useRef<number | null>(selectedConversationId) const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0) const currentUserIdRef = useRef<number>(session?.user.id ?? 0)
currentUserIdRef.current = session?.user.id ?? 0
useEffect(() => { useEffect(() => {
selectedConversationIdRef.current = selectedConversationId selectedConversationIdRef.current = selectedConversationId
}, [selectedConversationId]) }, [selectedConversationId])
useEffect(() => { useEffect(() => {
if (!accessToken) return
const isCurrentSession = () => readSession()?.accessToken === accessToken
const realtime = createRealtimeConnectionManager({ const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createAdminWebSocketUrl()), createSocket: () => new WebSocket(createAdminWebSocketUrl()),
onStatusChange: setRealtimeStatus, onStatusChange: (status) => { if (isCurrentSession()) setRealtimeStatus(status) },
onOpen: (socket) => { onOpen: (socket) => {
if (!isCurrentSession()) return
console.info("[agent-realtime] websocket connected", { console.info("[agent-realtime] websocket connected", {
url: socket.url, ...realtimeLogContext(socket.url),
}) })
const conversationId = selectedConversationIdRef.current const conversationId = selectedConversationIdRef.current
@@ -95,6 +105,7 @@ export function useAgentConversationRealtime() {
} }
}, },
onMessage: (event, socket) => { onMessage: (event, socket) => {
if (!isCurrentSession()) return
try { try {
const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope
const eventType = envelope.type ?? "" const eventType = envelope.type ?? ""
@@ -182,17 +193,16 @@ export function useAgentConversationRealtime() {
}, },
onClose: (event, socket) => { onClose: (event, socket) => {
console.log("[agent-realtime] websocket closed", { console.log("[agent-realtime] websocket closed", {
url: socket.url, ...realtimeLogContext(socket.url),
readyState: socket.readyState, readyState: socket.readyState,
code: event.code, code: event.code,
reason: event.reason,
wasClean: event.wasClean, wasClean: event.wasClean,
}) })
subscribedConversationIdRef.current = null subscribedConversationIdRef.current = null
}, },
onError: (_event, socket) => { onError: (_event, socket) => {
console.log("[agent-realtime] websocket error", { console.log("[agent-realtime] websocket error", {
url: socket.url, ...realtimeLogContext(socket.url),
readyState: socket.readyState, readyState: socket.readyState,
}) })
}, },
@@ -225,7 +235,7 @@ export function useAgentConversationRealtime() {
realtime.disconnect() realtime.disconnect()
subscribedConversationIdRef.current = null subscribedConversationIdRef.current = null
} }
}, [setRealtimeStatus, t]) }, [accessToken, setRealtimeStatus, t])
useEffect(() => { useEffect(() => {
const socket = realtimeRef.current?.getSocket() const socket = realtimeRef.current?.getSocket()

View File

@@ -1,4 +1,7 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: prevent stale profile updates and logout from modifying a newer session.
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth" import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { request } from "@/lib/api/client" import { request } from "@/lib/api/client"
export type LoginRequest = { export type LoginRequest = {
@@ -41,17 +44,17 @@ export async function fetchProfile() {
} }
export async function updateProfile(payload: UpdateProfileRequest) { export async function updateProfile(payload: UpdateProfileRequest) {
const stored = readSession()
const data = await request<AuthSession>("/api/auth/profile/update", { const data = await request<AuthSession>("/api/auth/profile/update", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
const stored = readSession()
const nextSession: AuthSession = { const nextSession: AuthSession = {
...data, ...data,
accessToken: data.accessToken || stored?.accessToken || "", accessToken: data.accessToken || stored?.accessToken || "",
expiresAt: data.expiresAt || stored?.expiresAt, expiresAt: data.expiresAt || stored?.expiresAt,
} }
writeSession(nextSession) if (isSameSession(stored, readSession())) writeSession(nextSession)
return nextSession return nextSession
} }
@@ -65,11 +68,12 @@ export function uploadProfileAvatar(file: File) {
} }
export async function logout() { export async function logout() {
const stored = readSession()
try { try {
await request("/api/auth/logout", { await request("/api/auth/logout", {
method: "POST", method: "POST",
}) })
} finally { } finally {
clearSession() if (isSameSession(stored, readSession())) clearSession()
} }
} }

View File

@@ -1,4 +1,7 @@
import { expireSession, readSession } from "@/lib/auth" // Modified in the independent hardening fork; see docs/hardening/README.md.
// Modified for AgentDeskFixes: scope authentication expiry to the session that issued each request.
import { expireSession, readSession, type AuthSession } from "@/lib/auth"
import { isSameSession } from "@/lib/session-request-guard"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
const API_BASE_URL = const API_BASE_URL =
@@ -50,10 +53,10 @@ function normalizeApiRequestError(error: unknown) {
) )
} }
async function parseResult<T>(response: Response) { async function parseResult<T>(response: Response, requestSession: AuthSession | null) {
const payload = (await response.json()) as JsonResult<T> const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) { if (!response.ok || !payload.success) {
if (payload.errorCode === 3000 || payload.errorCode === 3002) { if ((payload.errorCode === 3000 || payload.errorCode === 3002) && isSameSession(requestSession, readSession())) {
expireSession() expireSession()
} }
throw new ApiRequestError( throw new ApiRequestError(
@@ -65,11 +68,10 @@ async function parseResult<T>(response: Response) {
return payload.data return payload.data
} }
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) { function buildRequestHeaders(headers: HeadersInit | undefined, session: AuthSession | null, body?: BodyInit | null) {
const session = readSession()
const authHeaders = new Headers(headers) const authHeaders = new Headers(headers)
if (!skipAuth && session?.accessToken) { if (session?.accessToken) {
authHeaders.set("Authorization", `Bearer ${session.accessToken}`) authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
} }
if ( if (
@@ -101,7 +103,8 @@ export async function request<T>(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse 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 requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
try { try {
@@ -111,7 +114,7 @@ export async function request<T>(
cache: "no-store", cache: "no-store",
}) })
onResponse?.(response) onResponse?.(response)
return await parseResult<T>(response) return await parseResult<T>(response, requestSession)
} catch (error) { } catch (error) {
throw normalizeApiRequestError(error) throw normalizeApiRequestError(error)
} }
@@ -124,7 +127,8 @@ export async function requestBlob(
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse 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 requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, { const response = await fetch(`${requestBaseUrl}${path}`, {
...rest, ...rest,
@@ -136,7 +140,7 @@ export async function requestBlob(
const contentType = response.headers.get("Content-Type") ?? "" const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
try { try {
await parseResult<never>(response) await parseResult<never>(response, requestSession)
} catch (error) { } catch (error) {
throw error throw error
} }

View File

@@ -0,0 +1,173 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import vm from "node:vm"
import ts from "typescript"
const storage = () => {
const values = new Map()
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
}
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
})
function harness(request) {
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
const localStorage = storage(), sessionStorage = storage()
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
const module = { exports: {} }
const mocks = {
"@/lib/api/client": { request },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
}
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
return { api: module.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
}
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
const pending = deferred()
let calls = 0
const h = harness(() => { calls++; return pending.promise })
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
assert.equal(calls, 1)
pending.resolve(exchange())
const [left, right] = await Promise.all([first, second])
assert.equal(left.identityKey, "guest:server-assigned")
assert.equal(right.identityKey, left.identityKey)
assert.equal(left.guestContext, "client-guest")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("guest token cannot be reused under a different initiating visitor context", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
h.setConfig({ externalId: "someone-else" })
assert.equal(h.api.getCustomerSessionToken(), "")
const next = await h.api.ensureCustomerSession()
assert.equal(calls, 2)
assert.equal(next.identityKey, "guest:server-2")
})
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
let calls = 0
const h = harness(async () => { calls++; return exchange() })
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
assert.equal(h.api.getCustomerSessionToken(), "")
await h.api.ensureCustomerSession()
assert.equal(calls, 1)
})
test("late session exchange from old identity cannot overwrite new session", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "new-visitor" })
const next = await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-server"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
})
test("A to B to A entry changes invalidate old same-value exchange", async () => {
const old = deferred()
let calls = 0
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
const first = h.api.ensureCustomerSession()
h.setConfig({ externalId: "B" })
h.api.getCustomerIdentityScope()
h.setConfig({ externalId: "client-guest" })
await h.api.ensureCustomerSession()
old.resolve(exchange("guest:old-A"))
await assert.rejects(first, /supportAuthenticationRequired/)
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
})
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
let calls = 0
const h = harness(async () => exchange(`guest:server-${++calls}`))
await h.api.ensureCustomerSession()
const scope = h.api.getCustomerIdentityScope()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerIdentityScope(), scope)
await h.api.exchangeCustomerSession()
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
})
test("refresh with another auth mode or channel cannot poison cached session", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const original = h.api.getCustomerSessionToken()
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), original)
})
test("signed identity still requires exact user ID and correct auth mode", async () => {
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
assert.equal(h.api.getCustomerSessionToken(), "")
})
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
const h = harness(async () => exchange())
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
assert.equal(h.api.getCustomerSessionToken(), renewed)
})
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
const routes = []
const h = harness(async (route, options) => {
routes.push(route)
if (route.endsWith("session_refresh")) {
assert.equal(options.method, "POST")
assert.ok(options.headers.Authorization.startsWith("Bearer "))
}
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
const resumed = await h.api.ensureCustomerSession()
assert.equal(resumed.identityKey, cached.identityKey)
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
})
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
let calls = 0
const h = harness(async (route) => {
calls++
if (route.endsWith("session_refresh")) throw new Error("offline")
return exchange("guest:original")
})
await h.api.ensureCustomerSession()
const cached = h.api.readCustomerSession()
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
assert.equal(calls, 2)
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
})

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import { request } from "@/lib/api/client" import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
@@ -147,6 +148,8 @@ export type ImCustomerSessionExchangeResponse = {
export type ImCustomerSession = ImCustomerSessionExchangeResponse & { export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
channelId: string channelId: string
baseUrl: 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" 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() || "" process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
let entryIdentityExchangeKey = "" let entryIdentityExchangeKey = ""
let observedIdentityContext = ""
let identityGeneration = 0
let observedSessionIdentity = ""
let sessionIdentityGeneration = 0
let pendingSessionExchange: {
scope: string
promise: Promise<ImCustomerSession>
} | null = null
let pendingSessionRefresh: {
scope: string
promise: Promise<ImCustomerSession>
} | null = null
function buildGuestId() { function buildGuestId() {
return `guest_${generateUUID()}` 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) { function requireEntryToken(config: RuntimeImConfig) {
if (config.requireUserToken && !config.userToken) { if (config.requireUserToken && !config.userToken) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
@@ -217,6 +262,12 @@ function requireEntryToken(config: RuntimeImConfig) {
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) { function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
if (typeof session.identityKey !== "string") return false if (typeof session.identityKey !== "string") return false
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) 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) { if (config.userToken || config.requireUserToken) {
return ( return (
!!config.userToken && !!config.userToken &&
@@ -226,10 +277,8 @@ function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImCon
) )
} }
const externalId = config.externalId || getGuestId() const externalId = config.externalId || getGuestId()
return ( if (config.externalIdSignature) return session.identityKey === `user:${externalId}`
session.identityKey === `guest:${externalId}` || return session.identityKey.startsWith("guest:") && session.guestContext === externalId
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
)
} }
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) { function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
@@ -242,7 +291,7 @@ function usableCustomerSession(session: ImCustomerSession | null, config: Runtim
return true return true
} }
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null { function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string; identityScope?: string } | null {
try { try {
const payload = token.split(".")[1] const payload = token.split(".")[1]
if (!payload) return null if (!payload) return null
@@ -275,7 +324,7 @@ function isCustomerSessionValid(
if (identityKey && session.identityKey !== identityKey) { if (identityKey && session.identityKey !== identityKey) {
return false return false
} }
return parseExpiresAt(session.expiresAt) > Date.now() + 5000 return parseExpiresAt(session.expiresAt) > Date.now()
} }
export function readCustomerSession(): ImCustomerSession | null { export function readCustomerSession(): ImCustomerSession | null {
@@ -320,12 +369,15 @@ export function applyCustomerSessionRefresh(payload?: {
} }
const current = readCustomerSession() const current = readCustomerSession()
const config = getRuntimeImConfig() 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 // Decode only for cache consistency, never as proof of authenticity. The
// server still verifies signatures. A late old-account websocket/HTTP refresh // server still verifies signatures. A late old-account websocket/HTTP refresh
// must not overwrite the current account's stored customer session. // must not overwrite the current account's stored customer session.
const claims = sessionTokenClaims(token) 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({ writeCustomerSession({
...current, ...current,
customerSessionToken: token, customerSessionToken: token,
@@ -395,7 +447,7 @@ function createRequestOptions(
} { } {
const config = getRuntimeImConfig() const config = getRuntimeImConfig()
requireEntryToken(config) requireEntryToken(config)
const contextKey = identityContextKey(config) const identityScope = getCustomerIdentityScope()
return { return {
...init, ...init,
skipAuth: true, skipAuth: true,
@@ -404,7 +456,7 @@ function createRequestOptions(
...(init?.headers as Record<string, string> | undefined), ...(init?.headers as Record<string, string> | undefined),
}, },
onResponse: (response) => { onResponse: (response) => {
if (contextKey === identityContextKey(getRuntimeImConfig())) { if (identityScope === getCustomerIdentityScope()) {
applyCustomerSessionHeaders(response) applyCustomerSessionHeaders(response)
} }
}, },
@@ -428,7 +480,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
return output ? `?${output}` : "" return output ? `?${output}` : ""
} }
export async function exchangeCustomerSession() { async function exchangeCustomerSessionForScope(identityScope: string) {
const config = getRuntimeImConfig() const config = getRuntimeImConfig()
requireEntryToken(config) requireEntryToken(config)
const contextKey = identityContextKey(config) const contextKey = identityContextKey(config)
@@ -441,13 +493,14 @@ export async function exchangeCustomerSession() {
headers: createExchangeHeaders(), headers: createExchangeHeaders(),
} }
) )
if (contextKey !== identityContextKey(getRuntimeImConfig())) { if (identityScope !== getCustomerEntryScope()) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
} }
const session: ImCustomerSession = { const session: ImCustomerSession = {
...result, ...result,
channelId: config.channelId, channelId: config.channelId,
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
...(!config.userToken && !config.externalIdSignature ? { guestContext: config.externalId || getGuestId() } : {}),
} }
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) { if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired")) throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
@@ -457,14 +510,66 @@ export async function exchangeCustomerSession() {
return session 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() { export async function ensureCustomerSession() {
const config = getRuntimeImConfig() const config = getRuntimeImConfig()
requireEntryToken(config) requireEntryToken(config)
const cached = readCustomerSession() 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() return exchangeCustomerSession()
} }
function refreshCustomerSession(cached: ImCustomerSession) {
const scope = getCustomerIdentityScope()
if (pendingSessionRefresh?.scope === scope) return pendingSessionRefresh.promise
const refresh = async () => {
// Refresh with proof of the existing identity. An unsigned exchange would
// intentionally create a different guest and abandon the current history.
const result = await request<ImCustomerSessionExchangeResponse>("/api/customer/session_refresh", {
...createRequestOptions({ method: "POST" }),
})
if (scope !== getCustomerIdentityScope()) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
const renewed: ImCustomerSession = { ...cached, ...result }
if (!isCustomerSessionValid(renewed) || !matchesSessionIdentity(renewed, getRuntimeImConfig()) ||
renewed.identityKey !== cached.identityKey || renewed.customer.id !== cached.customer.id) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
writeCustomerSession(renewed)
return renewed
}
const promise = refresh()
pendingSessionRefresh = { scope, promise }
const clear = () => {
if (pendingSessionRefresh?.promise === promise) pendingSessionRefresh = null
}
void promise.then(clear, clear)
return promise
}
export function fetchImConversationDetail(id: number) { export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/conversation/${id}`, { return request<ImConversationDetail>(`/api/conversation/${id}`, {
...createRequestOptions(), ...createRequestOptions(),

View File

@@ -0,0 +1,115 @@
import assert from "node:assert/strict"
import test from "node:test"
import { isSameSession } from "../session-request-guard.ts"
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
function fixture() {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
let expirations = 0
const response = deferred()
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
expireSession: () => { session = null; expirations += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/i18n/messages": { translateCurrentMessage: (key) => key },
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
return {
client, response,
get session() { return session },
set session(next) { session = next },
get expirations() { return expirations },
}
}
function expiredResponse() {
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
status: 401, headers: { "Content-Type": "application/json" },
})
}
for (const method of ["request", "requestBlob"]) {
test(`${method}: old auth failure cannot expire a newer login`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session.user.id, 2)
assert.equal(f.expirations, 0)
})
test(`${method}: matching auth failure expires only its own session`, async () => {
const f = fixture()
const pending = f.client[method]("/test")
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.session, null)
assert.equal(f.expirations, 1)
})
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
const f = fixture()
const pending = f.client[method]("/test", { skipAuth: true })
f.response.resolve(expiredResponse())
await assert.rejects(pending, { errorCode: 3002 })
assert.equal(f.expirations, 0)
})
}
test("old logout and profile update cannot overwrite a new account", async () => {
for (const action of ["logout", "updateProfile"]) {
let session = { user: { id: 1 }, accessToken: "test-session-a" }
const response = deferred()
let writes = 0
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
"@/lib/auth": {
readSession: () => session,
clearSession: () => { session = null; writes += 1 },
writeSession: (next) => { session = next; writes += 1 },
},
"@/lib/session-request-guard": { isSameSession },
"@/lib/api/client": { request: () => response.promise },
})
const pending = auth[action]({ nickname: "Updated" })
session = { user: { id: 2 }, accessToken: "test-session-b" }
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
await pending
assert.equal(session.user.id, 2)
assert.equal(writes, 0)
}
})
for (const newerLogin of [true, false]) {
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
let session = null
const pendingLogout = deferred()
const redirects = []
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
react: {
createContext: () => ({ Provider: "context" }),
startTransition: (callback) => callback(),
useCallback: (callback) => callback,
useEffect: () => {},
},
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
"next/navigation": {
usePathname: () => "/dashboard",
useRouter: () => ({ replace: (url) => redirects.push(url) }),
},
"@/components/session-provider": {
SessionProvider: "session",
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
},
"@/lib/auth": { readSession: () => session },
})
const dashboardElement = auth.AuthProvider({ children: null }).props.children
const context = dashboardElement.type(dashboardElement.props)
const signOut = context.props.value.signOut()
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
pendingLogout.resolve()
await signOut
assert.equal(redirects.length, newerLogin ? 0 : 1)
})
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadSetupAPI(request) {
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
fileName: "setup-readiness.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
require(name) {
assert.equal(name, "@/lib/api/client")
return { request }
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("simulation normalizes null or absent arrays from older servers", async () => {
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
const api = await loadSetupAPI(async () => response)
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(Array.isArray(result.candidate_operators), true)
assert.equal(result.candidate_operators.length, 0)
assert.equal(Array.isArray(result.hops), true)
assert.equal(result.hops.length, 0)
}
})
test("simulation preserves returned candidates", async () => {
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
const result = await api.simulateSetupRoute({ channel_id: 7 })
assert.equal(result.candidate_operators, candidates)
})
test("quick fix preview does not manufacture apply intent", async () => {
const calls = []
const api = await loadSetupAPI(async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) })
return { applied: false, changes: [], preview_token: "reviewed" }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
assert.equal(calls[0].body.entity_id, 17)
assert.equal("apply" in calls[0].body, false)
assert.equal("preview_token" in calls[0].body, false)
})
test("explicit apply carries the reviewed target and fingerprint", async () => {
let sent
const api = await loadSetupAPI(async (_url, options) => {
sent = JSON.parse(options.body)
return { applied: true, changes: [], applied_actions: [] }
})
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
assert.equal(sent.apply, true)
assert.equal(sent.entity_id, 17)
assert.equal(sent.preview_token, "current-preview")
})

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
import { request } from "@/lib/api/client" import { request } from "@/lib/api/client"
export type SetupStepStatus = "ok" | "warning" | "error" export type SetupStepStatus = "ok" | "warning" | "error"
@@ -101,31 +102,51 @@ export type SetupSimulateRouteResponse = {
candidate_operators: SimulatedOperatorCandidate[] candidate_operators: SimulatedOperatorCandidate[]
} }
export type SetupQuickFixChange = {
entity_type: string
entity_id: number
entity_name: string
operation: "create" | "update"
before: Record<string, unknown> | null
after: Record<string, unknown>
}
export type SetupQuickFixResponse = { export type SetupQuickFixResponse = {
applied: boolean
preview_token: string
changes: SetupQuickFixChange[]
warning_codes: string[]
applied_actions: string[] applied_actions: string[]
readiness: SetupReadinessResponse readiness?: SetupReadinessResponse
} }
export function fetchSetupReadiness() { export function fetchSetupReadiness() {
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness") return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
} }
export function simulateSetupRoute(payload: { export async function simulateSetupRoute(payload: {
channel_id?: number channel_id?: number
bypass_ai?: boolean bypass_ai?: boolean
}) { }) {
return request<SetupSimulateRouteResponse>( const result = await request<SetupSimulateRouteResponse>(
"/api/dashboard/setup/simulate_route", "/api/dashboard/setup/simulate_route",
{ {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}, },
) )
return {
...result,
hops: result.hops ?? [],
candidate_operators: result.candidate_operators ?? [],
}
} }
export function applySetupQuickFix(payload: { export function applySetupQuickFix(payload: {
action: SetupQuickFixAction action: SetupQuickFixAction
entity_id?: number entity_id?: number
apply?: boolean
preview_token?: string
}) { }) {
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", { return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
method: "POST", method: "POST",

View File

@@ -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, "<p>For A</p>")
const b = store.get("2")
assert.equal(b.html, "")
store.update(b, "<p>For B</p>")
assert.equal(store.get("1").html, "<p>For A</p>")
assert.equal(store.get("2").html, "<p>For B</p>")
})
test("failed send preserves text and uploaded image metadata, and can retry", () => {
const store = createMessageEditorDraftStore()
const draft = store.get("1")
store.update(draft, '<p>Message</p><img title="uploading-test">')
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, '<p>Message</p><img title="uploading-test">')
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()
})

View File

@@ -0,0 +1,95 @@
import type { UploadedEditorImage } from "./im-editor-image"
export type MessageEditorDraft = {
html: string
revision: number
objectUrls: Set<string>
uploadedImages: Map<string, UploadedEditorImage & { url: string }>
}
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<string, MessageEditorDraft>()
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<typeof createMessageEditorDraftStore>

View File

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

View File

@@ -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<File, "name" | "type" | "arrayBuffer">) {
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])
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" 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") assert.ok(statusList.includes("disconnected"), "status should include disconnected")
manager.disconnect() 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()
})

View File

@@ -1,3 +1,4 @@
// Modified in the independent hardening fork; see docs/hardening/README.md.
"use client" "use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected" export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
@@ -157,15 +158,13 @@ export function createRealtimeConnectionManager(
}) })
nextSocket.addEventListener("close", (event) => { nextSocket.addEventListener("close", (event) => {
clearPingTimer()
const isCurrentSocket = socket === nextSocket const isCurrentSocket = socket === nextSocket
if (isCurrentSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) { if (!isCurrentSocket) {
return return
} }
clearPingTimer()
setSocket(null)
options.onClose?.(event, nextSocket)
if (canReconnect()) { if (canReconnect()) {
scheduleReconnect() scheduleReconnect()
} else { } else {
@@ -184,6 +183,9 @@ export function createRealtimeConnectionManager(
} }
const disconnect = (disconnectOptions?: DisconnectOptions) => { 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 reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers() clearTimers()
if (!reconnectEnabled) { if (!reconnectEnabled) {

View File

@@ -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: "" })
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" "use client"
import { create } from "zustand" import { create } from "zustand"
@@ -104,9 +106,9 @@ type AgentConversationsStore = {
loadOlderMessages: () => Promise<void> loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void> syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void> markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null> sendMessage: (html: string, conversationId?: number) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null> uploadImage: (file: File, conversationId?: number) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null> sendAttachment: (file: File, conversationId?: number) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null> recallMessage: (messageId: number) => Promise<AgentMessage | null>
applyRealtimeMessageCreated: (message: AgentMessage) => void applyRealtimeMessageCreated: (message: AgentMessage) => void
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
@@ -119,6 +121,22 @@ type AgentConversationsStore = {
let conversationsRequestSeq = 0 let conversationsRequestSeq = 0
let messagesRequestSeq = 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<AgentConversationsStore>((set, get) => ({ export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
searchKeyword: "", searchKeyword: "",
@@ -167,6 +185,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}, },
loadConversations: async () => { loadConversations: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const requestSeq = ++conversationsRequestSeq const requestSeq = ++conversationsRequestSeq
const store = get() const store = get()
@@ -180,7 +200,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
) )
const conversations = ensureArray(data.results) const conversations = ensureArray(data.results)
if (requestSeq !== conversationsRequestSeq) { if (requestSeq !== conversationsRequestSeq || !isCurrentSession()) {
return return
} }
@@ -216,7 +236,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}) })
} }
} catch (error) { } catch (error) {
if (requestSeq === conversationsRequestSeq) { if (requestSeq === conversationsRequestSeq && isCurrentSession()) {
set({ conversationsLoading: false }) set({ conversationsLoading: false })
} }
throw error throw error
@@ -246,10 +266,14 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}, },
loadMessages: async (conversationId, options = {}) => { loadMessages: async (conversationId, options = {}) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession() || get().selectedConversationId !== conversationId) return
const requestSeq = ++messagesRequestSeq const requestSeq = ++messagesRequestSeq
const store = get() const store = get()
const shouldShowLoading = const shouldShowLoading =
options.forceLoading || store.messagesLoadedConversationId !== conversationId options.forceLoading || store.messagesLoadedConversationId !== conversationId
// A fresh snapshot supersedes any older page request in the prior generation.
set({ messagesLoadingMore: false })
if (shouldShowLoading) { if (shouldShowLoading) {
set({ set({
@@ -270,7 +294,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
limit: 50, limit: 50,
}) })
if (requestSeq !== messagesRequestSeq) { if (requestSeq !== messagesRequestSeq || !isCurrentSession()) {
return return
} }
@@ -288,7 +312,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
messagesHasMore: Boolean(data.hasMore), messagesHasMore: Boolean(data.hasMore),
}) })
} catch (error) { } catch (error) {
if (requestSeq === messagesRequestSeq) { if (requestSeq === messagesRequestSeq && isCurrentSession()) {
set({ messagesLoading: false }) set({ messagesLoading: false })
} }
throw error throw error
@@ -296,6 +320,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}, },
loadOlderMessages: async () => { loadOlderMessages: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
const conversationId = get().selectedConversationId const conversationId = get().selectedConversationId
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) { if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
return return
@@ -312,7 +339,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
cursor: cursorId, cursor: cursorId,
limit: 50, limit: 50,
}) })
if (get().selectedConversationId !== conversationId) { if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return return
} }
const incoming = ensureArray(data.results) const incoming = ensureArray(data.results)
@@ -328,12 +355,17 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
} }
}) })
} catch (error) { } catch (error) {
set({ messagesLoadingMore: false }) if (isCurrentSession() && selectionGeneration === messagesRequestSeq && get().selectedConversationId === conversationId) {
set({ messagesLoadingMore: false })
}
throw error throw error
} }
}, },
syncLatestMessages: async (conversationId) => { syncLatestMessages: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const selectionGeneration = messagesRequestSeq
if (conversationId <= 0) { if (conversationId <= 0) {
return return
} }
@@ -342,7 +374,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
conversationId, conversationId,
limit: 50, limit: 50,
}) })
if (get().selectedConversationId !== conversationId) { if (!isCurrentSession() || selectionGeneration !== messagesRequestSeq || get().selectedConversationId !== conversationId) {
return return
} }
const batch = ensureArray(data.results) const batch = ensureArray(data.results)
@@ -370,6 +402,8 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}, },
markSelectedConversationRead: async () => { markSelectedConversationRead: async () => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
const store = get() const store = get()
const conversationId = store.selectedConversationId const conversationId = store.selectedConversationId
const conversation = store.conversations.find((item) => item.id === conversationId) const conversation = store.conversations.find((item) => item.id === conversationId)
@@ -390,6 +424,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
set({ readingMessageId: lastMessage.id }) set({ readingMessageId: lastMessage.id })
try { try {
await markAgentMessageRead(conversationId, lastMessage.id) await markAgentMessageRead(conversationId, lastMessage.id)
if (!isCurrentSession() || get().readingMessageId !== lastMessage.id) return
set((current) => { set((current) => {
if (current.selectedConversationId !== conversationId) { if (current.selectedConversationId !== conversationId) {
return { readingMessageId: 0 } return { readingMessageId: 0 }
@@ -414,7 +449,9 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
} }
}) })
} catch (error) { } catch (error) {
set({ readingMessageId: 0 }) if (isCurrentSession() && get().readingMessageId === lastMessage.id) {
set({ readingMessageId: 0 })
}
throw error throw error
} }
}, },
@@ -513,7 +550,10 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
}, },
resyncRealtimeData: async (conversationId) => { resyncRealtimeData: async (conversationId) => {
const isCurrentSession = captureSessionScope()
if (!isCurrentSession()) return
await get().loadConversations() await get().loadConversations()
if (!isCurrentSession()) return
const selectedConversationId = get().selectedConversationId const selectedConversationId = get().selectedConversationId
const targetConversationId = conversationId ?? selectedConversationId const targetConversationId = conversationId ?? selectedConversationId
if (targetConversationId && selectedConversationId === targetConversationId) { if (targetConversationId && selectedConversationId === targetConversationId) {
@@ -524,10 +564,15 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
} }
}, },
sendMessage: async (html) => { sendMessage: async (html, recipientId) => {
const isCurrentSession = captureSessionScope()
const trimmedContent = html.trim() const trimmedContent = html.trim()
const { selectedConversationId, sending } = get() const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) { if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
!trimmedContent || sending || get().uploadingAsset
) {
return null return null
} }
@@ -540,7 +585,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_${generateUUID()}`, clientMsgId: `agent_${generateUUID()}`,
}) })
if (get().selectedConversationId === selectedConversationId) { if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({ set((current) => ({
messages: current.messages.some((m) => m.id === message.id) messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m)) ? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -561,33 +606,45 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message return message
} finally { } finally {
set({ sending: false }) if (isCurrentSession()) set({ sending: false })
} }
}, },
uploadImage: async (file) => { uploadImage: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get() const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) { if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null return null
} }
set({ uploadingAsset: true }) set({ uploadingAsset: true })
try { try {
return await uploadAgentConversationImage(selectedConversationId, file) const asset = await uploadAgentConversationImage(selectedConversationId, file)
return isCurrentSession() ? asset : null
} finally { } finally {
set({ uploadingAsset: false }) if (isCurrentSession()) set({ uploadingAsset: false })
} }
}, },
sendAttachment: async (file) => { sendAttachment: async (file, recipientId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, sending, uploadingAsset } = get() const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) { if (
!isCurrentSession() || !selectedConversationId ||
(recipientId !== undefined && recipientId !== selectedConversationId) ||
sending || uploadingAsset
) {
return null return null
} }
set({ uploadingAsset: true }) set({ uploadingAsset: true })
try { try {
const asset = await uploadAgentConversationAttachment(selectedConversationId, file) const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
if (!isCurrentSession()) return null
const message = await sendAgentMessage({ const message = await sendAgentMessage({
conversationId: selectedConversationId, conversationId: selectedConversationId,
messageType: "attachment", messageType: "attachment",
@@ -596,7 +653,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
clientMsgId: `agent_attachment_${generateUUID()}`, clientMsgId: `agent_attachment_${generateUUID()}`,
}) })
if (get().selectedConversationId === selectedConversationId) { if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => ({ set((current) => ({
messages: current.messages.some((m) => m.id === message.id) messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m)) ? current.messages.map((m) => (m.id === message.id ? message : m))
@@ -617,20 +674,22 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
return message return message
} finally { } finally {
set({ uploadingAsset: false }) if (isCurrentSession()) set({ uploadingAsset: false })
} }
}, },
recallMessage: async (messageId) => { recallMessage: async (messageId) => {
const isCurrentSession = captureSessionScope()
const { selectedConversationId, recallingMessageId } = get() const { selectedConversationId, recallingMessageId } = get()
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) { if (!isCurrentSession() || !selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
return null return null
} }
set({ recallingMessageId: messageId }) set({ recallingMessageId: messageId })
try { try {
const message = await recallAgentMessage(messageId) const message = await recallAgentMessage(messageId)
if (get().selectedConversationId === selectedConversationId) { if (!isCurrentSession()) return null
if (isCurrentSession() && get().selectedConversationId === selectedConversationId) {
set((current) => { set((current) => {
const nextMessages = current.messages.map((item) => const nextMessages = current.messages.map((item) =>
item.id === message.id ? message : item item.id === message.id ? message : item
@@ -656,11 +715,11 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
} }
}) })
} else { } else {
set({ recallingMessageId: 0 }) if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
} }
return message return message
} catch (error) { } catch (error) {
set({ recallingMessageId: 0 }) if (isCurrentSession() && get().recallingMessageId === messageId) set({ recallingMessageId: 0 })
throw error throw error
} }
}, },

View File

@@ -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: `<p>${id}</p>`,
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("<p>Hello</p>")
await assert.rejects(h.store.getState().handleSendMessage("<p>Hello</p>"), /sendInProgress/)
assert.equal(sent.length, 1)
first.reject(new Error("response lost"))
await assert.rejects(pending, /response lost/)
await h.store.getState().handleSendMessage("<p>Hello</p>")
assert.equal(sent[0].clientMsgId, sent[1].clientMsgId)
await h.store.getState().handleSendMessage("<p>Hello</p>")
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)
})

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "Start your first request", "startFirstRequest": "Start your first request",
"openRequestCount": "{open} open of {total}", "openRequestCount": "{open} open of {total}",
"loadConversationsFailed": "Could not load your requests", "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": { "dashboardHome": {
"rangeToday": "Today", "rangeToday": "Today",
@@ -3370,8 +3375,8 @@
"statusReady": "Ready to Receive Requests", "statusReady": "Ready to Receive Requests",
"statusNeedsSetup": "Setup Incomplete ({ready}/{total} steps ready)", "statusNeedsSetup": "Setup Incomplete ({ready}/{total} steps ready)",
"stepsProgress": "{ready} of {total} steps ready", "stepsProgress": "{ready} of {total} steps ready",
"quickSetupAll": "Configure Everything in 1 Click", "quickSetupAll": "Review missing defaults",
"quickSetupRunning": "Applying setup...", "quickSetupRunning": "Preparing preview...",
"quickFixSuccess": "Configuration updated and routing re-checked", "quickFixSuccess": "Configuration updated and routing re-checked",
"refresh": "Re-check Readiness", "refresh": "Re-check Readiness",
"openSettings": "Open Settings", "openSettings": "Open Settings",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "Channel is not connected to an AI agent.", "channel_no_agent": "Channel is not connected to an AI agent.",
"channel_agent_disabled": "Channel points to a disabled or missing 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.", "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": { "operatorBlockers": {
"not_authenticated": "You are not signed in.", "not_authenticated": "You are not signed in.",
@@ -3446,22 +3453,22 @@
"at_capacity": "You have reached your maximum active conversation limit." "at_capacity": "You have reached your maximum active conversation limit."
}, },
"quickFixLabels": { "quickFixLabels": {
"quick_setup_all": "Configure Everything in 1 Click", "quick_setup_all": "Review missing defaults",
"ensure_team_and_schedule": "Create/Enable Team & 24/7 Schedule", "ensure_team_and_schedule": "Review team setup",
"enable_my_operator_profile": "Enable My Reception & Set Idle", "enable_my_operator_profile": "Enable My Reception & Set Idle",
"fix_ai_agent_handoff": "Fix AI Agent Handoff & Bind Teams", "fix_ai_agent_handoff": "Fix AI Agent Handoff & Bind Teams",
"ensure_default_channel": "Create/Fix Default Web Channel" "ensure_default_channel": "Create/Fix Default Web Channel"
}, },
"simulator": { "simulator": {
"title": "Operator Route 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...", "selectChannelPlaceholder": "Select a channel to simulate...",
"runSimulation": "Simulate Dispatch", "runSimulation": "Simulate Dispatch",
"running": "Simulating...", "running": "Simulating...",
"outcomeSuccess": "Request WILL reach operator: {operator}", "outcomeSuccess": "Eligible operator now: {operator}",
"outcomeBlocked": "Request WILL NOT reach an operator", "outcomeBlocked": "Operator delivery not confirmed",
"outcomes": { "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_no_channel": "Blocked: No channels exist or the requested channel was not found.",
"blocked_channel_disabled": "Blocked: This channel is disabled.", "blocked_channel_disabled": "Blocked: This channel is disabled.",
"blocked_zero_rollout": "Blocked: Channel AI agent rollout rate is set to 0%.", "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_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.", "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_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": { "hops": {
"channel": "Channel", "channel": "Channel",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "No active teams assigned to this agent", "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_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", "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", "candidateTableTitle": "Evaluated Operator Candidates",
"colOperator": "Operator", "colOperator": "Operator",
@@ -3520,6 +3533,53 @@
"headerIndicator": { "headerIndicator": {
"readyLabel": "Ready for Chats", "readyLabel": "Ready for Chats",
"blockedLabel": "Routing Blocked ({count})" "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."
} }
} }
} }

View File

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

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "Создайте первое обращение", "startFirstRequest": "Создайте первое обращение",
"openRequestCount": "Открыто {open} из {total}", "openRequestCount": "Открыто {open} из {total}",
"loadConversationsFailed": "Не удалось загрузить список обращений", "loadConversationsFailed": "Не удалось загрузить список обращений",
"createConversationFailed": "Не удалось создать обращение" "createConversationFailed": "Не удалось создать обращение",
"dismissedTitle": "Окно чата закрыто",
"dismissedDescription": "Ваше обращение не завершено. Вы можете вернуться и продолжить разговор.",
"returnToChat": "Вернуться в чат",
"sendUnavailable": "Диалог недоступен. Вернитесь к нему или повторите попытку перед отправкой.",
"sendInProgress": "Сообщение уже отправляется."
}, },
"dashboardHome": { "dashboardHome": {
"rangeToday": "Сегодня", "rangeToday": "Сегодня",
@@ -3370,8 +3375,8 @@
"statusReady": "Готово к приёму обращений", "statusReady": "Готово к приёму обращений",
"statusNeedsSetup": "Требуется настройка (готово {ready} из {total} шагов)", "statusNeedsSetup": "Требуется настройка (готово {ready} из {total} шагов)",
"stepsProgress": "Готово {ready} из {total} шагов", "stepsProgress": "Готово {ready} из {total} шагов",
"quickSetupAll": "Настроить всё в 1 клик", "quickSetupAll": "Проверить недостающие настройки",
"quickSetupRunning": "Применение настроек...", "quickSetupRunning": "Подготовка плана...",
"quickFixSuccess": "Настройки применены, проверка маршрутизации обновлена", "quickFixSuccess": "Настройки применены, проверка маршрутизации обновлена",
"refresh": "Проверить снова", "refresh": "Проверить снова",
"openSettings": "Открыть настройки", "openSettings": "Открыть настройки",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "Канал не подключен к AI-агенту.", "channel_no_agent": "Канал не подключен к AI-агенту.",
"channel_agent_disabled": "Канал указывает на отключенного или удаленного AI-агента.", "channel_agent_disabled": "Канал указывает на отключенного или удаленного AI-агента.",
"channel_zero_rollout": "У канала охват AI-агента равен 0%.", "channel_zero_rollout": "У канала охват AI-агента равен 0%.",
"no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас." "no_end_to_end_path": "Ни один канал не может доставить запрос до доступного оператора прямо сейчас.",
"agent_unpublished": "У агента нет опубликованной версии. Опубликуйте его в настройках перед использованием в канале.",
"channel_agent_unpublished": "Канал связан с неопубликованным агентом. Опубликуйте его или выберите опубликованного агента."
}, },
"operatorBlockers": { "operatorBlockers": {
"not_authenticated": "Вы не вошли в систему.", "not_authenticated": "Вы не вошли в систему.",
@@ -3446,22 +3453,22 @@
"at_capacity": "Вы достигли лимита одновременных диалогов." "at_capacity": "Вы достигли лимита одновременных диалогов."
}, },
"quickFixLabels": { "quickFixLabels": {
"quick_setup_all": "Настроить всё в 1 клик", "quick_setup_all": "Проверить недостающие настройки",
"ensure_team_and_schedule": "Создать/включить команду и расписание 24/7", "ensure_team_and_schedule": "Проверить настройку группы",
"enable_my_operator_profile": "Включить мой приём и статус «Свободен»", "enable_my_operator_profile": "Включить мой приём и статус «Свободен»",
"fix_ai_agent_handoff": "Настроить передачу оператору и привязать команды", "fix_ai_agent_handoff": "Настроить передачу оператору и привязать команды",
"ensure_default_channel": "Создать/исправить веб-канал по умолчанию" "ensure_default_channel": "Создать/исправить веб-канал по умолчанию"
}, },
"simulator": { "simulator": {
"title": "Симулятор маршрутизации к оператору", "title": "Симулятор маршрутизации к оператору",
"description": "Протестируйте путь входящего сообщения клиента через канал и узнайте результат на каждом узле маршрутизации.", "description": "Проверка текущей доступности маршрута только с оператором без изменений. Поведение ИИ и частичный запуск не моделируются; сообщения не отправляются.",
"selectChannelPlaceholder": "Выберите канал для симуляции...", "selectChannelPlaceholder": "Выберите канал для симуляции...",
"runSimulation": "Запустить симуляцию", "runSimulation": "Запустить симуляцию",
"running": "Симуляция...", "running": "Симуляция...",
"outcomeSuccess": "Запрос ДОЙДЁТ до оператора: {operator}", "outcomeSuccess": "Сейчас доступен оператор: {operator}",
"outcomeBlocked": "Запрос НЕ ДОЙДЁТ до оператора", "outcomeBlocked": "Передача оператору не подтверждена",
"outcomes": { "outcomes": {
"assigned_to_operator": "Успех: обращение назначено свободному оператору.", "assigned_to_operator": "Для маршрута только с оператором сейчас есть подходящий сотрудник. Это проверка без изменений, не гарантия доставки.",
"blocked_no_channel": "Заблокировано: каналы не созданы или запрошенный канал не найден.", "blocked_no_channel": "Заблокировано: каналы не созданы или запрошенный канал не найден.",
"blocked_channel_disabled": "Заблокировано: данный канал отключен.", "blocked_channel_disabled": "Заблокировано: данный канал отключен.",
"blocked_zero_rollout": "Заблокировано: охват AI-агента в канале равен 0%.", "blocked_zero_rollout": "Заблокировано: охват AI-агента в канале равен 0%.",
@@ -3469,7 +3476,10 @@
"blocked_ai_only_mode": "Заблокировано: AI-агент работает в режиме «Только ИИ» и не переключает на человека.", "blocked_ai_only_mode": "Заблокировано: AI-агент работает в режиме «Только ИИ» и не переключает на человека.",
"blocked_agent_no_teams": "Заблокировано: у AI-агента не указаны активные команды для перевода.", "blocked_agent_no_teams": "Заблокировано: у AI-агента не указаны активные команды для перевода.",
"queued_off_hours_no_schedule": "В очереди: у команд сейчас нет активного расписания смен (нерабочие часы).", "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": { "hops": {
"channel": "Канал", "channel": "Канал",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "Агенту не назначены активные команды", "blocked_agent_no_teams": "Агенту не назначены активные команды",
"queued_off_hours_no_schedule": "Нет активного расписания на текущее время (нерабочие часы)", "queued_off_hours_no_schedule": "Нет активного расписания на текущее время (нерабочие часы)",
"queued_no_eligible_operator": "В активных командах нет свободных операторов с доступной емкостью", "queued_no_eligible_operator": "В активных командах нет свободных операторов с доступной емкостью",
"assigned_to_operator": "Назначено подходящему оператору" "assigned_to_operator": "Назначено подходящему оператору",
"ai_execution_not_simulated": "Поведение ИИ не моделируется; передача оператору не подтверждена.",
"partial_rollout_not_simulated": "Частичный запуск не моделируется; передача оператору не подтверждена.",
"blocked_agent_unpublished": "Маршрутизация к оператору не подтверждена для неопубликованного агента. Сначала опубликуйте его."
}, },
"candidateTableTitle": "Оценка кандидатов-операторов", "candidateTableTitle": "Оценка кандидатов-операторов",
"colOperator": "Оператор", "colOperator": "Оператор",
@@ -3520,6 +3533,53 @@
"headerIndicator": { "headerIndicator": {
"readyLabel": "Приём включен", "readyLabel": "Приём включен",
"blockedLabel": "Приём заблокирован ({count})" "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": "Опубликуйте нужного агента в его настройках перед созданием канала. Мастер не публикует агентов и не обходит это требование."
} }
} }
} }

View File

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

View File

@@ -583,7 +583,12 @@
"startFirstRequest": "开始第一个请求", "startFirstRequest": "开始第一个请求",
"openRequestCount": "进行中 {open} / 共 {total}", "openRequestCount": "进行中 {open} / 共 {total}",
"loadConversationsFailed": "加载请求列表失败", "loadConversationsFailed": "加载请求列表失败",
"createConversationFailed": "创建请求失败" "createConversationFailed": "创建请求失败",
"dismissedTitle": "聊天窗口已关闭",
"dismissedDescription": "您的支持请求尚未结束,您可以返回继续对话。",
"returnToChat": "返回聊天",
"sendUnavailable": "当前对话不可用,请返回对话或重试后再发送。",
"sendInProgress": "正在发送消息,请稍候。"
}, },
"dashboardHome": { "dashboardHome": {
"rangeToday": "今天", "rangeToday": "今天",
@@ -3370,8 +3375,8 @@
"statusReady": "已就绪,可正常接待请求", "statusReady": "已就绪,可正常接待请求",
"statusNeedsSetup": "配置未就绪(已完成 {ready}/{total} 步)", "statusNeedsSetup": "配置未就绪(已完成 {ready}/{total} 步)",
"stepsProgress": "已就绪 {ready} / {total} 步", "stepsProgress": "已就绪 {ready} / {total} 步",
"quickSetupAll": "一键完成全部基础配置", "quickSetupAll": "预览缺失的默认配置",
"quickSetupRunning": "正在应用配置...", "quickSetupRunning": "正在准备预览…",
"quickFixSuccess": "配置已自动修复并重新检测", "quickFixSuccess": "配置已自动修复并重新检测",
"refresh": "重新检测", "refresh": "重新检测",
"openSettings": "前往设置", "openSettings": "前往设置",
@@ -3433,7 +3438,9 @@
"channel_no_agent": "接入渠道未关联 AI 智能体。", "channel_no_agent": "接入渠道未关联 AI 智能体。",
"channel_agent_disabled": "接入渠道关联的 AI 智能体已被禁用或不存在。", "channel_agent_disabled": "接入渠道关联的 AI 智能体已被禁用或不存在。",
"channel_zero_rollout": "接入渠道的 AI 智能体分流比例为 0%。", "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": { "operatorBlockers": {
"not_authenticated": "您尚未登录系统。", "not_authenticated": "您尚未登录系统。",
@@ -3446,22 +3453,22 @@
"at_capacity": "您的当前接待会话量已达上限。" "at_capacity": "您的当前接待会话量已达上限。"
}, },
"quickFixLabels": { "quickFixLabels": {
"quick_setup_all": "一键完成全部基础配置", "quick_setup_all": "预览缺失的默认配置",
"ensure_team_and_schedule": "创建/启用客服组及 24/7 排班", "ensure_team_and_schedule": "预览客服组设置",
"enable_my_operator_profile": "启用我的坐席并设为空闲", "enable_my_operator_profile": "启用我的坐席并设为空闲",
"fix_ai_agent_handoff": "修复 AI 转人工模式并绑定客服组", "fix_ai_agent_handoff": "修复 AI 转人工模式并绑定客服组",
"ensure_default_channel": "创建/修复默认 Web 渠道" "ensure_default_channel": "创建/修复默认 Web 渠道"
}, },
"simulator": { "simulator": {
"title": "人工坐席路由模拟器", "title": "人工坐席路由模拟器",
"description": "模拟客户通过指定渠道进线后的完整路由流转过程,逐级查看每个节点的调度决策。", "description": "只读检查当前仅人工路由的接待条件。不模拟 AI 行为或部分灰度,不会发送消息。",
"selectChannelPlaceholder": "选择要模拟的渠道...", "selectChannelPlaceholder": "选择要模拟的渠道...",
"runSimulation": "开始模拟分发", "runSimulation": "开始模拟分发",
"running": "正在模拟...", "running": "正在模拟...",
"outcomeSuccess": "请求将成功分配给坐席:{operator}", "outcomeSuccess": "当前符合条件的客服:{operator}",
"outcomeBlocked": "请求无法到达人工坐席", "outcomeBlocked": "未确认可送达人工客服",
"outcomes": { "outcomes": {
"assigned_to_operator": "成功:会话将分配给处于空闲状态的就绪坐席。", "assigned_to_operator": "仅人工路由当前有符合条件的客服。这是只读检查,不保证实际送达。",
"blocked_no_channel": "受阻:系统中没有可用渠道或指定的渠道不存在。", "blocked_no_channel": "受阻:系统中没有可用渠道或指定的渠道不存在。",
"blocked_channel_disabled": "受阻:该接入渠道已被禁用。", "blocked_channel_disabled": "受阻:该接入渠道已被禁用。",
"blocked_zero_rollout": "受阻:该渠道的 AI 智能体分流比例为 0%。", "blocked_zero_rollout": "受阻:该渠道的 AI 智能体分流比例为 0%。",
@@ -3469,7 +3476,10 @@
"blocked_ai_only_mode": "受阻:AI 智能体为纯 AI 模式,禁止转接人工。", "blocked_ai_only_mode": "受阻:AI 智能体为纯 AI 模式,禁止转接人工。",
"blocked_agent_no_teams": "受阻:AI 智能体未配置有效的转接客服组。", "blocked_agent_no_teams": "受阻:AI 智能体未配置有效的转接客服组。",
"queued_off_hours_no_schedule": "排队中:目标客服组当前无生效排班(非工作时间)。", "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": { "hops": {
"channel": "接入渠道", "channel": "接入渠道",
@@ -3491,7 +3501,10 @@
"blocked_agent_no_teams": "AI 智能体未关联有效客服组", "blocked_agent_no_teams": "AI 智能体未关联有效客服组",
"queued_off_hours_no_schedule": "当前时刻无生效中的工作排班(非工作时段)", "queued_off_hours_no_schedule": "当前时刻无生效中的工作排班(非工作时段)",
"queued_no_eligible_operator": "当前客服组内暂无空闲且容量充足的坐席", "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": "参评候选坐席明细", "candidateTableTitle": "参评候选坐席明细",
"colOperator": "坐席", "colOperator": "坐席",
@@ -3520,6 +3533,53 @@
"headerIndicator": { "headerIndicator": {
"readyLabel": "接单就绪", "readyLabel": "接单就绪",
"blockedLabel": "接单受阻 ({count})" "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 或绕过发布要求。"
} }
} }
} }

View File

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