Compare commits
1 Commits
main
...
hardening-
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e0457886 |
22
AGENTS.md
22
AGENTS.md
@@ -273,3 +273,25 @@ Before handing off a change, confirm the applicable items:
|
|||||||
- Generated files were regenerated from their source and were not manually edited.
|
- 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
104
MODIFIED_FILES.md
Normal 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`
|
||||||
@@ -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.
|
||||||
|
|||||||
109
docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md
Normal file
109
docs/hardening/CUSTOMER_IDENTITY_UPGRADE.md
Normal 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
63
docs/hardening/README.md
Normal 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.
|
||||||
56
docs/hardening/SETUP_PREVIEW.md
Normal file
56
docs/hardening/SETUP_PREVIEW.md
Normal 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.
|
||||||
47
docs/hardening/VALIDATION.md
Normal file
47
docs/hardening/VALIDATION.md
Normal 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.
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"` // 为外部身份来源
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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: "Не удалось сохранить настройки канала. Попробуйте ещё раз."
|
||||||
|
|||||||
@@ -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: "无法保存渠道配置,请重试。"
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
68
internal/repositories/auth_scope_repository.go
Normal file
68
internal/repositories/auth_scope_repository.go
Normal 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
|
||||||
|
}
|
||||||
20
internal/repositories/channel_config_repository.go
Normal file
20
internal/repositories/channel_config_repository.go
Normal 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
|
||||||
|
}
|
||||||
17
internal/repositories/conversation_dispatch_queries.go
Normal file
17
internal/repositories/conversation_dispatch_queries.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
51
internal/repositories/customer_identity_scope.go
Normal file
51
internal/repositories/customer_identity_scope.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
246
internal/services/auth_session_security_test.go
Normal file
246
internal/services/auth_session_security_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/services/channel_policy_concurrency_test.go
Normal file
78
internal/services/channel_policy_concurrency_test.go
Normal 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")}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
272
internal/services/customer_identity_security_test.go
Normal file
272
internal/services/customer_identity_security_test.go
Normal 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")}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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") }
|
||||||
}
|
}
|
||||||
|
|||||||
184
internal/services/ws_auth_security_test.go
Normal file
184
internal/services/ws_auth_security_test.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
104
internal/services/ws_pump_security_test.go
Normal file
104
internal/services/ws_pump_security_test.go
Normal 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")}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
30
internal/services/ws_session_concurrency_test.go
Normal file
30
internal/services/ws_session_concurrency_test.go
Normal 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") }
|
||||||
|
}
|
||||||
47
scripts/audit-customer-identity-scopes.sql
Normal file
47
scripts/audit-customer-identity-scopes.sql
Normal 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;
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
173
web/lib/api/im-session.test.mjs
Normal file
173
web/lib/api/im-session.test.mjs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import test from "node:test"
|
||||||
|
import vm from "node:vm"
|
||||||
|
import ts from "typescript"
|
||||||
|
|
||||||
|
const storage = () => {
|
||||||
|
const values = new Map()
|
||||||
|
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) }
|
||||||
|
}
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve, reject
|
||||||
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
const token = (identityKey, channelCode = "support", mode = "guest", extra = {}) =>
|
||||||
|
`test.${Buffer.from(JSON.stringify({ identityKey, channelCode, identityScope: `v1:${mode}:test-channel-scope`, ...extra })).toString("base64url")}.not-a-real-signature`
|
||||||
|
const exchange = (identityKey = "guest:server-assigned", channelCode = "support", mode = "guest") => ({
|
||||||
|
identityKey, customerSessionToken: token(identityKey, channelCode, mode),
|
||||||
|
expiresAt: "2099-01-01T00:00:00Z", customer: { id: 1, name: "Visitor" },
|
||||||
|
})
|
||||||
|
|
||||||
|
function harness(request) {
|
||||||
|
let config = { baseUrl: "https://support.example", channelId: "support", externalId: "client-guest" }
|
||||||
|
const localStorage = storage(), sessionStorage = storage()
|
||||||
|
const source = readFileSync(new URL("./im.ts", import.meta.url), "utf8")
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } })
|
||||||
|
const module = { exports: {} }
|
||||||
|
const mocks = {
|
||||||
|
"@/lib/api/client": { request },
|
||||||
|
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||||
|
"@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => config },
|
||||||
|
"@/lib/utils": { generateUUID: () => "browser-local-guest", parseApiTimestamp: Date.parse },
|
||||||
|
}
|
||||||
|
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
|
||||||
|
require: (name) => { if (!mocks[name]) throw new Error(`Unexpected import ${name}`); return mocks[name] },
|
||||||
|
process: { env: {} }, window: { localStorage, sessionStorage }, atob, TextDecoder, URLSearchParams })
|
||||||
|
return { api: module.exports, sessionStorage, setConfig: (next) => { config = { ...config, ...next } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("concurrent session ensures share one guest exchange and cache server-owned identity", async () => {
|
||||||
|
const pending = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => { calls++; return pending.promise })
|
||||||
|
const first = h.api.ensureCustomerSession(), second = h.api.ensureCustomerSession()
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
pending.resolve(exchange())
|
||||||
|
const [left, right] = await Promise.all([first, second])
|
||||||
|
assert.equal(left.identityKey, "guest:server-assigned")
|
||||||
|
assert.equal(right.identityKey, left.identityKey)
|
||||||
|
assert.equal(left.guestContext, "client-guest")
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("guest token cannot be reused under a different initiating visitor context", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
h.setConfig({ externalId: "someone-else" })
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||||
|
const next = await h.api.ensureCustomerSession()
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(next.identityKey, "guest:server-2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("legacy unscoped tokens are re-exchanged instead of sent to protected endpoints", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return exchange() })
|
||||||
|
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...exchange("guest:client-guest"),
|
||||||
|
customerSessionToken: token("guest:client-guest", "support", "guest", { identityScope: undefined }),
|
||||||
|
baseUrl: "https://support.example", channelId: "support", guestContext: "client-guest" }))
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("late session exchange from old identity cannot overwrite new session", async () => {
|
||||||
|
const old = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-server")))
|
||||||
|
const first = h.api.ensureCustomerSession()
|
||||||
|
h.setConfig({ externalId: "new-visitor" })
|
||||||
|
const next = await h.api.ensureCustomerSession()
|
||||||
|
old.resolve(exchange("guest:old-server"))
|
||||||
|
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||||
|
assert.equal(h.api.readCustomerSession().identityKey, next.identityKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A to B to A entry changes invalidate old same-value exchange", async () => {
|
||||||
|
const old = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => ++calls === 1 ? old.promise : Promise.resolve(exchange("guest:new-A")))
|
||||||
|
const first = h.api.ensureCustomerSession()
|
||||||
|
h.setConfig({ externalId: "B" })
|
||||||
|
h.api.getCustomerIdentityScope()
|
||||||
|
h.setConfig({ externalId: "client-guest" })
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
old.resolve(exchange("guest:old-A"))
|
||||||
|
await assert.rejects(first, /supportAuthenticationRequired/)
|
||||||
|
assert.equal(h.api.readCustomerSession().identityKey, "guest:new-A")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("customer token rotation preserves scope, but replacement guest identity changes it", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => exchange(`guest:server-${++calls}`))
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
const scope = h.api.getCustomerIdentityScope()
|
||||||
|
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-1", "support", "guest", { revision: 2 }), expiresAt: "2099-02-01T00:00:00Z" })
|
||||||
|
assert.equal(h.api.getCustomerIdentityScope(), scope)
|
||||||
|
await h.api.exchangeCustomerSession()
|
||||||
|
assert.notEqual(h.api.getCustomerIdentityScope(), scope)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refresh with another auth mode or channel cannot poison cached session", async () => {
|
||||||
|
const h = harness(async () => exchange())
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
const original = h.api.getCustomerSessionToken()
|
||||||
|
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "support", "jwt"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||||
|
h.api.applyCustomerSessionRefresh({ customerSessionToken: token("guest:server-assigned", "other"), expiresAt: "2099-02-01T00:00:00Z" })
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), original)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("signed identity still requires exact user ID and correct auth mode", async () => {
|
||||||
|
const h = harness(async () => exchange("user:wrong", "support", "hmac"))
|
||||||
|
h.setConfig({ externalId: "expected", externalIdSignature: "test-not-a-real-signature", externalIdSignedAt: 1 })
|
||||||
|
await assert.rejects(h.api.ensureCustomerSession(), /supportAuthenticationRequired/)
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), "")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matching renewal arriving during old token expiry window keeps guest identity", async () => {
|
||||||
|
const h = harness(async () => exchange())
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
const cached = h.api.readCustomerSession()
|
||||||
|
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: "2000-01-01T00:00:00Z" }))
|
||||||
|
const renewed = token(cached.identityKey, "support", "guest", { revision: 2 })
|
||||||
|
h.api.applyCustomerSessionRefresh({ customerSessionToken: renewed, expiresAt: "2099-02-01T00:00:00Z" })
|
||||||
|
assert.equal(h.api.getCustomerSessionToken(), renewed)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("near-expiry guest renews its existing identity instead of exchanging a fresh guest", async () => {
|
||||||
|
const routes = []
|
||||||
|
const h = harness(async (route, options) => {
|
||||||
|
routes.push(route)
|
||||||
|
if (route.endsWith("session_refresh")) {
|
||||||
|
assert.equal(options.method, "POST")
|
||||||
|
assert.ok(options.headers.Authorization.startsWith("Bearer "))
|
||||||
|
}
|
||||||
|
return exchange("guest:original")
|
||||||
|
})
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
const cached = h.api.readCustomerSession()
|
||||||
|
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||||
|
const resumed = await h.api.ensureCustomerSession()
|
||||||
|
assert.equal(resumed.identityKey, cached.identityKey)
|
||||||
|
assert.deepEqual(routes, ["/api/customer/session_exchange", "/api/customer/session_refresh"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed near-expiry refresh never falls back to a different anonymous guest", async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async (route) => {
|
||||||
|
calls++
|
||||||
|
if (route.endsWith("session_refresh")) throw new Error("offline")
|
||||||
|
return exchange("guest:original")
|
||||||
|
})
|
||||||
|
await h.api.ensureCustomerSession()
|
||||||
|
const cached = h.api.readCustomerSession()
|
||||||
|
h.sessionStorage.setItem("cs_ai_agent_customer_session", JSON.stringify({ ...cached, expiresAt: new Date(Date.now() + 4000).toISOString() }))
|
||||||
|
await assert.rejects(h.api.ensureCustomerSession(), /offline/)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(h.api.readCustomerSession().identityKey, "guest:original")
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
||||||
import { request } from "@/lib/api/client"
|
import { 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(),
|
||||||
|
|||||||
115
web/lib/api/session-isolation.test.mjs
Normal file
115
web/lib/api/session-isolation.test.mjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { isSameSession } from "../session-request-guard.ts"
|
||||||
|
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||||
|
let expirations = 0
|
||||||
|
const response = deferred()
|
||||||
|
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
|
||||||
|
"@/lib/auth": {
|
||||||
|
readSession: () => session,
|
||||||
|
expireSession: () => { session = null; expirations += 1 },
|
||||||
|
},
|
||||||
|
"@/lib/session-request-guard": { isSameSession },
|
||||||
|
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
||||||
|
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
|
||||||
|
return {
|
||||||
|
client, response,
|
||||||
|
get session() { return session },
|
||||||
|
set session(next) { session = next },
|
||||||
|
get expirations() { return expirations },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiredResponse() {
|
||||||
|
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
|
||||||
|
status: 401, headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const method of ["request", "requestBlob"]) {
|
||||||
|
test(`${method}: old auth failure cannot expire a newer login`, async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const pending = f.client[method]("/test")
|
||||||
|
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||||
|
f.response.resolve(expiredResponse())
|
||||||
|
await assert.rejects(pending, { errorCode: 3002 })
|
||||||
|
assert.equal(f.session.user.id, 2)
|
||||||
|
assert.equal(f.expirations, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${method}: matching auth failure expires only its own session`, async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const pending = f.client[method]("/test")
|
||||||
|
f.response.resolve(expiredResponse())
|
||||||
|
await assert.rejects(pending, { errorCode: 3002 })
|
||||||
|
assert.equal(f.session, null)
|
||||||
|
assert.equal(f.expirations, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
|
||||||
|
const f = fixture()
|
||||||
|
const pending = f.client[method]("/test", { skipAuth: true })
|
||||||
|
f.response.resolve(expiredResponse())
|
||||||
|
await assert.rejects(pending, { errorCode: 3002 })
|
||||||
|
assert.equal(f.expirations, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("old logout and profile update cannot overwrite a new account", async () => {
|
||||||
|
for (const action of ["logout", "updateProfile"]) {
|
||||||
|
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
||||||
|
const response = deferred()
|
||||||
|
let writes = 0
|
||||||
|
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
|
||||||
|
"@/lib/auth": {
|
||||||
|
readSession: () => session,
|
||||||
|
clearSession: () => { session = null; writes += 1 },
|
||||||
|
writeSession: (next) => { session = next; writes += 1 },
|
||||||
|
},
|
||||||
|
"@/lib/session-request-guard": { isSameSession },
|
||||||
|
"@/lib/api/client": { request: () => response.promise },
|
||||||
|
})
|
||||||
|
const pending = auth[action]({ nickname: "Updated" })
|
||||||
|
session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||||
|
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
|
||||||
|
await pending
|
||||||
|
assert.equal(session.user.id, 2)
|
||||||
|
assert.equal(writes, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const newerLogin of [true, false]) {
|
||||||
|
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
|
||||||
|
let session = null
|
||||||
|
const pendingLogout = deferred()
|
||||||
|
const redirects = []
|
||||||
|
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
|
||||||
|
react: {
|
||||||
|
createContext: () => ({ Provider: "context" }),
|
||||||
|
startTransition: (callback) => callback(),
|
||||||
|
useCallback: (callback) => callback,
|
||||||
|
useEffect: () => {},
|
||||||
|
},
|
||||||
|
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
|
||||||
|
"next/navigation": {
|
||||||
|
usePathname: () => "/dashboard",
|
||||||
|
useRouter: () => ({ replace: (url) => redirects.push(url) }),
|
||||||
|
},
|
||||||
|
"@/components/session-provider": {
|
||||||
|
SessionProvider: "session",
|
||||||
|
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
|
||||||
|
},
|
||||||
|
"@/lib/auth": { readSession: () => session },
|
||||||
|
})
|
||||||
|
const dashboardElement = auth.AuthProvider({ children: null }).props.children
|
||||||
|
const context = dashboardElement.type(dashboardElement.props)
|
||||||
|
const signOut = context.props.value.signOut()
|
||||||
|
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
|
||||||
|
pendingLogout.resolve()
|
||||||
|
await signOut
|
||||||
|
assert.equal(redirects.length, newerLogin ? 0 : 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
67
web/lib/api/setup-readiness.test.mjs
Normal file
67
web/lib/api/setup-readiness.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import ts from "typescript"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import vm from "node:vm"
|
||||||
|
|
||||||
|
async function loadSetupAPI(request) {
|
||||||
|
const source = await readFile(new URL("./setup-readiness.ts", import.meta.url), "utf8")
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS },
|
||||||
|
fileName: "setup-readiness.ts",
|
||||||
|
})
|
||||||
|
const sandbox = {
|
||||||
|
exports: {},
|
||||||
|
module: { exports: {} },
|
||||||
|
require(name) {
|
||||||
|
assert.equal(name, "@/lib/api/client")
|
||||||
|
return { request }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sandbox.exports = sandbox.module.exports
|
||||||
|
vm.runInNewContext(compiled.outputText, sandbox)
|
||||||
|
return sandbox.module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
test("simulation normalizes null or absent arrays from older servers", async () => {
|
||||||
|
for (const response of [{ candidate_operators: null, hops: null }, {}]) {
|
||||||
|
const api = await loadSetupAPI(async () => response)
|
||||||
|
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||||
|
assert.equal(Array.isArray(result.candidate_operators), true)
|
||||||
|
assert.equal(result.candidate_operators.length, 0)
|
||||||
|
assert.equal(Array.isArray(result.hops), true)
|
||||||
|
assert.equal(result.hops.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("simulation preserves returned candidates", async () => {
|
||||||
|
const candidates = [{ user_id: 17, display_name: "Operator", eligible: true }]
|
||||||
|
const api = await loadSetupAPI(async () => ({ candidate_operators: candidates, hops: [] }))
|
||||||
|
const result = await api.simulateSetupRoute({ channel_id: 7 })
|
||||||
|
assert.equal(result.candidate_operators, candidates)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("quick fix preview does not manufacture apply intent", async () => {
|
||||||
|
const calls = []
|
||||||
|
const api = await loadSetupAPI(async (url, options) => {
|
||||||
|
calls.push({ url, body: JSON.parse(options.body) })
|
||||||
|
return { applied: false, changes: [], preview_token: "reviewed" }
|
||||||
|
})
|
||||||
|
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17 })
|
||||||
|
assert.equal(calls[0].url, "/api/dashboard/setup/quick_fix")
|
||||||
|
assert.equal(calls[0].body.entity_id, 17)
|
||||||
|
assert.equal("apply" in calls[0].body, false)
|
||||||
|
assert.equal("preview_token" in calls[0].body, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("explicit apply carries the reviewed target and fingerprint", async () => {
|
||||||
|
let sent
|
||||||
|
const api = await loadSetupAPI(async (_url, options) => {
|
||||||
|
sent = JSON.parse(options.body)
|
||||||
|
return { applied: true, changes: [], applied_actions: [] }
|
||||||
|
})
|
||||||
|
await api.applySetupQuickFix({ action: "fix_ai_agent_handoff", entity_id: 17, apply: true, preview_token: "current-preview" })
|
||||||
|
assert.equal(sent.apply, true)
|
||||||
|
assert.equal(sent.entity_id, 17)
|
||||||
|
assert.equal(sent.preview_token, "current-preview")
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
||||||
import { request } from "@/lib/api/client"
|
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",
|
||||||
|
|||||||
99
web/lib/message-editor-drafts.test.mjs
Normal file
99
web/lib/message-editor-drafts.test.mjs
Normal 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()
|
||||||
|
})
|
||||||
95
web/lib/message-editor-drafts.ts
Normal file
95
web/lib/message-editor-drafts.ts
Normal 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>
|
||||||
14
web/lib/message-file-identity.test.mjs
Normal file
14
web/lib/message-file-identity.test.mjs
Normal 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")))
|
||||||
|
})
|
||||||
13
web/lib/message-file-identity.ts
Normal file
13
web/lib/message-file-identity.ts
Normal 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])
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
11
web/lib/realtime-log-context.test.mjs
Normal file
11
web/lib/realtime-log-context.test.mjs
Normal 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: "" })
|
||||||
|
})
|
||||||
9
web/lib/realtime-log-context.ts
Normal file
9
web/lib/realtime-log-context.ts
Normal 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: "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
web/lib/session-request-guard.test.mjs
Normal file
32
web/lib/session-request-guard.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
20
web/lib/session-request-guard.ts
Normal file
20
web/lib/session-request-guard.ts
Normal 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())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
127
web/lib/stores/agent-conversations.test.mjs
Normal file
127
web/lib/stores/agent-conversations.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
289
web/lib/stores/support-chat.test.mjs
Normal file
289
web/lib/stores/support-chat.test.mjs
Normal 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
28
web/lib/test-support/load-typescript-module.mjs
Normal file
28
web/lib/test-support/load-typescript-module.mjs
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
web/messages/en-US.json.license
Normal file
2
web/messages/en-US.json.license
Normal 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.
|
||||||
@@ -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": "Опубликуйте нужного агента в его настройках перед созданием канала. Мастер не публикует агентов и не обходит это требование."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
web/messages/ru-RU.json.license
Normal file
2
web/messages/ru-RU.json.license
Normal 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.
|
||||||
@@ -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 或绕过发布要求。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
web/messages/zh-CN.json.license
Normal file
2
web/messages/zh-CN.json.license
Normal 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.
|
||||||
Reference in New Issue
Block a user