diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md index 5124a744a..7f5399273 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -1,134 +1,68 @@ -# Channel configuration service access recovery +# Channel service selection -Channel service selection and the user's NyxID authorization are separate -decisions. The editor shows the services the current session can select; -Manage service access opens the existing full NyxID consent flow. It never -promises a consent page limited to the services named in a link. +Bind and Edit list active services the user's NyxID account can use, regardless +of which services the user selected at login. The Services header has no Manage +service access action, and the channel form does not redirect into OAuth consent. +This supersedes the former login-grant filtering and consent-return draft flow. -## Link contract +## Source and authorization boundary -Use repeated `requiredServiceId` query parameters on the canonical edit or Bind URL: +The authenticated `GET /api/v1/user-services` inventory owns service identity, +activity and account access. Human-session inventory is independent of OAuth +service selections. The console does not decode `allowed_service_ids` or +`allow_all_services` to constrain channel selection. Personal services and +organization services allowed by membership are selectable when active; NyxID +organization viewer entries (`credential_source.allowed=false`) remain unavailable. -```text -/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId -/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId&requiredServiceId=:anotherUserServiceId -``` - -Each value is the exact NyxID **UserService ID**, obtained from an authoritative -service reference. A catalog ID, display name, slug, channel registration ID, -or Aevatar published-service ID is not interchangeable with this identity. -The editor trims and deduplicates hints, accepts at most 20 nonempty values of -at most 128 characters each, and ignores invalid values. Hints are not grants, -do not select a service automatically, and do not become channel requirements. - -Names and slugs come from the authenticated NyxID user-service inventory. -Effective availability requires an active service, account access, and the -current bearer grant for that exact ID (or an explicit all-services grant). -Another service with the same slug cannot satisfy the hint. Missing inventory -entries are shown as unresolved services with the requested ID behind a -details disclosure; unavailable services are not presented as selectable. - -## User path - -1. The user opens the edit or Bind link. Services lists the missing requested access - above the existing searchable channel selection. -2. Manage service access saves the non-secret, unsaved label, skill name and - selected IDs in tab-scoped session storage before leaving. A storage or - redirect failure keeps the editor open with a retryable error. -3. The existing `NyxIDAuthClient` starts `serviceAccessReview` with the complete - configuration path, query and fragment as `returnTo`. It uses the existing PKCE, - callback and backend finalization flow. It supplies no targeted `resource` - or `preselect_service_ids` parameters. -4. NyxID currently displays the full consent page. The editor directs users to - **Customize** under **Service access**, retain services they still need, - select the listed services, and choose **Allow**. Viewing that page does not - imply that any permission was granted. -5. Returning reloads the actual service inventory/grants and restores the - editor draft. New available services are marked Requested but remain - unselected until the user chooses them. Partial or cancelled authorization - leaves the remaining access needs visible. The callback's return action is - labeled Back to previous page because it may return to this editor. - Restored edits receive one short inline reminder to review selections and - save. Do not show a generic permission-check success heading or panel; - only unresolved access needs warrant a separate notice and service list. -6. The user selects services and clicks **Save changes** or **Bind bot** explicitly. - Both actions follow the channel's existing accepted-to-observed confirmation. - Access review itself never saves or binds the channel. After a successful access - refresh, deleted, inactive or unauthorized services disappear from the selection - list and are removed from the draft, selected count and next submission. There - are no Unavailable placeholder rows or manual-deselection warnings. Later - reauthorization makes a service selectable again without restoring its old - selection. Pending or failed access requests never erase draft choices. - Required built-in services still block submission when unavailable. A missing - service explicitly requested by the URL remains in the separate access notice. +Both Bind and Edit submit exact UserService IDs with +`authorization_mode=explicit_service_allowlist`. Saving uses the existing backend +registration authorization planner: it verifies active instances and ownership, +asks NyxID for the Agent Key scope plan, and creates or updates that independent +channel credential. This behavior was checked against `feature/integrate`; +no backend contract change is needed. Account access does not prove credential +health, and the server revalidates each submitted selection. -Both routes reuse `ChannelConfigurationPage`, `ChannelServicePicker` and -`ChannelServiceAccessNotice`, including loading, retry and revoked-selection cleanup -behavior. Bind needs no saved channel registration to review access. +## Link hints and user path -The temporary draft is keyed by account subject, scope and a typed target: -`bind + botId` for an unbound bot, or `edit + registrationId` for a saved channel. -These identities never substitute for each other, even if raw ID strings coincide. -The draft has a one-hour expiry and is cleared after restoration, completed save -or binding, or explicit discard. It contains no credentials and cannot establish -authorization or a saved channel fact. Browser history restoration resets pending -review state and refreshes service access. +Repeated `requiredServiceId` parameters on the canonical Bind or Edit URL remain +advisory hints for exact UserService IDs: -On Bind, a restored skill choice takes precedence over the link's `skillId` -default, including an explicitly cleared choice. Authorization return preserves -the complete link but does not reapply the default or automatically select newly -authorized services. Binding still targets the original bot ID and completes only -after the returned registration ID and bot ID are observed with the submitted -configuration. - -## Current NyxID review limitation - -The ordinary `prompt=consent` page can initialize from the app's default -services instead of the user's latest saved consent. On 2026-09-29, the live -review page showed the original six services even though the latest Authorized -Apps entry contained the two additionally granted UserService IDs. Both extra -services were available but unchecked under Customize. Merely opening the -review did not remove the saved grant. - -The user must include every service they intend to retain before submitting -that ordinary review. Its consent decision replaces the selected service -boundary; the channel's draft selections do not initialize NyxID's picker. -Do not send the consent page's server-generated `preselect_service_ids` as -an invented `/oauth/authorize` contract, or substitute slug-based `resource` -parameters: resource requests can narrow issued authority and cannot reliably -represent distinct same-slug UserServices. +```text +/scopes/:scopeId/channels/:registrationId/edit?requiredServiceId=:userServiceId +/scopes/:scopeId/channels/bind/:botId?skillId=:optionalSkillId&requiredServiceId=:userServiceId +``` -[NyxID PR #1683](https://github.com/ChronoAIProject/NyxID/pull/1683) introduces -explicit incremental consent with `service_access_mode=incremental` and exact -repeated `requested_service_ids`. It was open during this investigation. -After the backend and consent UI deploy, Aevatar must integrate that contract -and verify repeated consent preserves the accumulated grant. This full-review -fallback does not claim that capability. +The editor trims and deduplicates hints, accepts at most 20 nonempty values of at +most 128 characters, and ignores invalid values. Hints neither select services +nor add channel requirements. Active matching instances are marked Requested; +a same-slug instance never substitutes for the requested ID. Missing or inactive +hints get an availability notice with unresolved IDs behind a details disclosure. +There are no instructions to customize login consent. + +Users search and select services, then explicitly click Bind bot or Save changes. +The optional Skill's backend suggestions use this same inventory boundary. +Required built-in services retain their existing selection and validation rules. +A successful inventory refresh removes missing, inactive or account-denied +selections from the draft, selected count and next submission. Active services +outside login consent remain selected. Failed or pending refreshes preserve edits +and block saving. Reactivation makes services selectable without reselecting them. + +There is no channel consent redirect or temporary session-storage draft. Unsaved +navigation retains its ordinary discard protection. Saving still completes only +when the submitted configuration is observed, not when a command is accepted. ## Verification and visual direction -Keep the existing compact white work surface, AlibabaSans typography, blue -actions and token-based amber access notice. Missing services are a short list -with readable names and slugs; the existing search and checkboxes remain the -channel-selection controls. Actions and rows wrap at mobile widths. - -Route integration tests exercise exact-ID hints, duplicate slugs, partial -authorization, explicit selection/save, draft restoration, cancellation, -redirect/storage failure and account isolation. Existing adapter and callback -tests protect grant validation and the shared return flow. Browser history -restoration coverage verifies fresh grants, temporary draft cleanup and the -removal of revoked selections from the list, count and submission, while failed -refreshes preserve the draft. Bind route coverage -also verifies the complete return URL, explicit binding after refreshed grants, -accepted-versus-observed completion, restored skill overrides and clearing, and -isolation across bots and edit registrations. Full frontend -typecheck, suite and production build are delegated to GitHub CI. +Keep the compact Channels form, search, selected count, typography and design +tokens. Route integration tests cover Bind and Edit with empty login grants, +exact-instance selection, explicit submission, Bind observation, inactive/deleted +cleanup, failed-refresh preservation and reactivation. Adapter tests cover account +inventory, organization availability, authentication rejection and token refresh. +Full frontend typecheck, suite and production build are delegated to GitHub CI. -Design baseline: -`../../design-baselines/workflow-activity-vnext/`, primary +Design baseline: `../../design-baselines/workflow-activity-vnext/`, primary `aevatar-workflow-activity-vnext.excalidraw`, SHA-256 `30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. Contract: `2026-08-04-workflow-activity-vnext-design.md`. User paths: `2026-08-04-workflow-activity-vnext-user-paths.md`. -Existing auth/session/returnTo and Umi localization remain authoritative. Production data comes from real APIs and acknowledged user actions only. diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md new file mode 100644 index 000000000..c80566240 --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -0,0 +1,62 @@ +# Channel Skill service suggestions + +Issue: https://github.com/aevatarAI/aevatar/issues/3678 + +## Backend-owned discovery + +The console calls `GET /api/skills/service-recommendations?skillName=...` on +Mainnet. The backend owns Skill resolution, service discovery and evidence. +See the [backend contract](https://github.com/aevatarAI/aevatar/blob/3da66b8813c03a7b1d87616889dd3ff343dcde1a/docs/contracts/skill-service-recommendations.md) +for the response, source contracts, layering and limitations. + +The backend is delivered in [PR #3683](https://github.com/aevatarAI/aevatar/pull/3683) +against `feature/integrate`. This console change is delivered separately in +[PR #3679](https://github.com/aevatarAI/aevatar/pull/3679) against +`feat/2026-08-04_workflow-activity-vnext`. The backend endpoint must be merged +and deployed for recommendations to become available. + +The browser does not fetch `SKILL.md`, load the catalog for inference, or match +service names against Skill content. Its API adapter validates the selected +Skill identity, evidence enums and exact UserService instance IDs. A missing or +unsupported backend result produces a retryable discovery state, with the +manual picker still usable; there is no local inference fallback. + +## User behavior + +Binding and editing show suggestions after a Skill is selected. Reasons name the +backend's evidence category without exposing private instructions. Account +instances show personal/organization source, activity and availability. +Active services available to the NyxID account determine which exact IDs are +selectable, including services omitted at login. Account-level organization +restrictions still apply. Login service grants do not constrain a channel +Agent Key selection. Both Bind and Edit use this same inventory boundary; see +[Channel service selection](2026-09-28-channel-service-access.md). + +Selecting an instance adds its exact UserService ID to the form; normal save +commits it. Discovery never selects, authorizes or removes services. Changing or +clearing the Skill preserves manual choices. Queries are keyed by scope and +Skill name and consume abort signals, so late results do not replace the current +Skill's recommendations. + +Missing connections and inactive services offer NyxID connection management in a +new tab. Channel forms no longer show an OAuth service-access review action or +consent instructions. Refresh reloads backend discovery and account inventory. +Pending and failed discovery stay within the suggestion region. + +The current backend sources do not declare mandatory dependencies. Suggestions +remain advisory; text mentions explicitly say they may be needed for some tasks. +An empty result does not prove no services are required. Existing platform- +required services keep their own behavior. Inventory is not credential-validity +evidence. + +## Design and verification + +Retain the compact Channels form, design tokens, wrapped names, keyboard actions +and English/Chinese locales. Excalidraw assets remain unchanged: +`30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. +Use real authenticated backend responses; mocks exist only in tests. + +Route integration tests cover rendering backend suggestions, exact instance +selection and save, access gaps, switching/clearing, stale responses and recovery. +API tests cover request encoding and malformed/mismatched results. +Full frontend typecheck, suite and production build are delegated to GitHub CI. diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index bd2d2c291..06f041910 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -1,4 +1,33 @@ export default { + 'channels.suggestions.title': 'Suggested for {skill}', + 'channels.suggestions.refresh': 'Refresh suggestions', + 'channels.suggestions.loading': 'Finding related services...', + 'channels.suggestions.error': + 'Could not identify related services. Refresh to retry, or select services manually below.', + 'channels.suggestions.help': + 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', + 'channels.suggestions.linked': 'Linked to this skill in Ornn.', + 'channels.suggestions.catalog': 'The service catalog recommends this skill.', + 'channels.suggestions.mention': + 'Mentioned in the skill description or instructions; may be needed for some tasks.', + 'channels.suggestions.unknownSource': 'Unknown source', + 'channels.suggestions.checking': 'Checking access...', + 'channels.suggestions.accessError': + 'Could not check access. Refresh to retry.', + 'channels.suggestions.inactive': 'Inactive — manage this service in NyxID.', + 'channels.suggestions.unavailable': + 'Access unavailable — check with the service owner.', + 'channels.suggestions.missingInstance': + 'No active connection found. Refresh or check this service in NyxID.', + 'channels.suggestions.notSelected': 'Not selected', + 'channels.suggestions.selected': 'Selected', + 'channels.suggestions.selectNamed': 'Select {service}', + 'channels.suggestions.select': 'Select', + 'channels.suggestions.notConnected': + 'No connection found in your account. Add this service in NyxID.', + 'channels.suggestions.empty': + 'No related services identified. You can still select services manually below.', + 'channels.suggestions.manage': 'Manage connections in NyxID ↗', 'channels.column.owner': 'Owner', 'channels.owner.organization': 'Organization', 'channels.owner.id': 'Owner ID', @@ -82,7 +111,7 @@ export default { 'channels.edit.servicesError': 'Could not load your services. Try again before saving.', 'channels.edit.servicesEmpty': - 'No services are available with your current authorization.', + 'No active services are available in your NyxID account.', 'channels.edit.missingServices': 'Some saved services are no longer available. Deselect them before saving.', 'channels.edit.selectionError': @@ -202,12 +231,12 @@ export default { 'channels.connect.selectAll': 'Select all', 'channels.connect.selectAllResults': 'Select all results', 'channels.connect.servicesHelp': - 'Only services available through your current NyxID authorization are shown.', + 'Choose active services from your NyxID account, including services you did not select when signing in.', 'channels.connect.servicesLoading': 'Loading services', 'channels.connect.servicesError': 'Could not load your services. Retry before connecting.', 'channels.connect.servicesEmpty': - 'No services are available with your current NyxID authorization.', + 'No active services are available in your NyxID account.', 'channels.connect.serviceRequired': 'Required', 'channels.connect.requiredServicesMissing': 'Required services unavailable: {services}. Check your NyxID access, then retry.', @@ -251,18 +280,12 @@ export default { 'channels.connect.stay': 'Stay', 'channels.connect.discardHelp': 'Your bot token and unsaved choices will be cleared.', - 'channels.access.manage': 'Manage service access', - 'channels.access.help': - 'Missing a service? Click Manage service access to authorize it.', - 'channels.access.needed': 'Service access needed', + 'channels.access.needed': 'Requested services unavailable', 'channels.access.instructions': - 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', + 'Check these services in NyxID. Active services your account can use can be selected here.', 'channels.access.unknown': 'Service not found', 'channels.access.requestedIdentity': 'Requested service ID', 'channels.access.unavailable': 'Check availability in NyxID', - 'channels.access.notAuthorized': 'Access needed', - 'channels.access.restored': 'Your changes have been kept. Review and save.', - 'channels.access.startFailed': - 'Could not open NyxID. Your changes are still here. Try again.', + 'channels.access.notAuthorized': 'Account access unavailable', 'channels.access.requested': 'Requested', }; diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index 9ef384980..040c54242 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -1,4 +1,30 @@ export default { + 'channels.suggestions.title': '为 {skill} 推荐', + 'channels.suggestions.refresh': '刷新推荐', + 'channels.suggestions.loading': '正在识别相关服务…', + 'channels.suggestions.error': + '暂时无法识别相关服务。请刷新重试,或在下方手动选择。', + 'channels.suggestions.help': + '推荐结果可能不完整,也不代表必需依赖。请按任务需要选择服务,保存 Channel 后生效。', + 'channels.suggestions.linked': '该 Skill 在 Ornn 中关联了此服务。', + 'channels.suggestions.catalog': '服务目录将该 Skill 列为推荐技能。', + 'channels.suggestions.mention': + 'Skill 的描述或说明提到了此服务,部分任务可能需要。', + 'channels.suggestions.unknownSource': '未知来源', + 'channels.suggestions.checking': '正在检查访问权限…', + 'channels.suggestions.accessError': '暂时无法检查访问权限,请刷新重试。', + 'channels.suggestions.inactive': '服务未启用,请前往 NyxID 管理。', + 'channels.suggestions.unavailable': '暂无访问权限,请联系服务所有者。', + 'channels.suggestions.missingInstance': + '未找到 active 状态的连接,请刷新或在 NyxID 检查该服务。', + 'channels.suggestions.notSelected': '尚未选择', + 'channels.suggestions.selected': '已选择', + 'channels.suggestions.selectNamed': '选择 {service}', + 'channels.suggestions.select': '选择', + 'channels.suggestions.notConnected': + '账号中未找到此服务的连接,请前往 NyxID 添加。', + 'channels.suggestions.empty': '未识别到相关服务。你仍可在下方手动选择。', + 'channels.suggestions.manage': '在 NyxID 管理连接 ↗', 'channels.column.owner': '归属', 'channels.owner.organization': '组织', 'channels.owner.id': '归属 ID', @@ -74,7 +100,7 @@ export default { 'channels.edit.useDefaults': '使用 NyxID 默认授权', 'channels.edit.unavailableService': '不可用的服务', 'channels.edit.servicesError': '无法加载服务,请重试后再保存。', - 'channels.edit.servicesEmpty': '当前授权范围内暂无可用服务。', + 'channels.edit.servicesEmpty': 'NyxID 账号中暂无 active 状态的可用服务。', 'channels.edit.missingServices': '部分已保存的服务已不可用,请取消勾选后再保存。', 'channels.edit.selectionError': '请检查选中的服务后重试。', @@ -179,10 +205,11 @@ export default { 'channels.connect.selected': '已选 {count} 项', 'channels.connect.selectAll': '全选', 'channels.connect.selectAllResults': '全选搜索结果', - 'channels.connect.servicesHelp': '仅显示当前 NyxID 授权范围内可用的服务。', + 'channels.connect.servicesHelp': + '可选择 NyxID 账号中处于 active 状态的可用服务,无需在登录时勾选。', 'channels.connect.servicesLoading': '正在加载服务', 'channels.connect.servicesError': '无法加载服务,请重试后再连接。', - 'channels.connect.servicesEmpty': '当前 NyxID 授权范围内暂无可用服务。', + 'channels.connect.servicesEmpty': 'NyxID 账号中暂无 active 状态的可用服务。', 'channels.connect.serviceRequired': '必选', 'channels.connect.requiredServicesMissing': '必选服务不可用:{services}。请检查 NyxID 访问权限后重试。', @@ -220,16 +247,12 @@ export default { 'channels.connect.discard': '放弃', 'channels.connect.stay': '继续填写', 'channels.connect.discardHelp': '机器人令牌和未保存的选择将被清除。', - 'channels.access.manage': '管理服务权限', - 'channels.access.help': '缺少服务?点击「管理服务权限」补充授权。', - 'channels.access.needed': '需要补充服务权限', + 'channels.access.needed': '所需服务暂不可用', 'channels.access.instructions': - '在 NyxID 的 Service access 中点击 Customize。保留仍需使用的已选服务,勾选以下服务,再点击 Allow。', + '请在 NyxID 检查以下服务,账号中处于 active 状态的可用服务可直接在此选择。', 'channels.access.unknown': '未找到该服务', 'channels.access.requestedIdentity': '所需服务 ID', 'channels.access.unavailable': '请在 NyxID 检查服务是否可用', - 'channels.access.notAuthorized': '需要授权', - 'channels.access.restored': '修改已保留,确认后请保存。', - 'channels.access.startFailed': '无法打开 NyxID,当前修改已保留,请重试。', + 'channels.access.notAuthorized': '账号暂无使用权限', 'channels.access.requested': '本次需要', }; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx index d912f83cd..72980489c 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelBindServiceAccess.test.tsx @@ -1,6 +1,5 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { NyxIDAuthClient } from '@/shared/auth/client'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; @@ -73,10 +72,6 @@ const writes = () => fetchMock.mock.calls.filter(([, init]) => ['POST', 'PATCH'].includes(init?.method ?? ''), ); -const detailReads = () => - fetchMock.mock.calls.filter(([input]) => - String(input).endsWith(`/skills/${skillId}`), - ); function grant(ids = baseIds) { persistAuthSession(createNyxIDServiceSession({ allowed_service_ids: ids })); } @@ -138,43 +133,22 @@ beforeEach(() => { }); }); -it('restores an unbound bot draft and fresh requested access after consent, then binds only on explicit submission and confirms the observed result', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('Service access needed'); - expect(screen.getByText('Firecrawl')).toBeInTheDocument(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); +it('binds with active services outside login consent only after explicit selection and confirms the observed result', async () => { + grant([]); + const { queryClient } = mount(); + const firecrawl = await screen.findByRole('checkbox', { name: /Firecrawl/ }); + expect(firecrawl).toBeEnabled(); + expect(firecrawl).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Lark Bot API/ })).toBeEnabled(); expect( - screen.queryByRole('checkbox', { name: /Firecrawl/ }), + screen.queryByRole('button', { name: /Manage service access/ }), ).not.toBeInTheDocument(); + expect(screen.queryByText(/Missing a service/)).not.toBeInTheDocument(); + await screen.findByText('linked-default'); await chooseSupport(); fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(review).toHaveBeenCalledWith({ - flow: 'serviceAccessReview', - returnTo: bindHref(), - }), - ); - expect(writes()).toHaveLength(0); - first.unmount(); - - grant([...baseIds, 'us-firecrawl']); - const second = mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect(screen.queryByText('linked-default')).not.toBeInTheDocument(); - expect(detailReads()).toHaveLength(1); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); - expect(firecrawl).not.toBeChecked(); - expect(writes()).toHaveLength(0); fireEvent.click(firecrawl); + expect(writes()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); await screen.findByText('Confirming your changes...'); expect(writes()).toHaveLength(1); @@ -206,7 +180,7 @@ it('restores an unbound bot draft and fresh requested access after consent, then : normalFetch(input, init), ); await act(async () => { - await second.queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: ['channels', 'scope-alpha', 'confirmation', 'cmd-alpha'], }); }); @@ -217,107 +191,3 @@ it('restores an unbound bot draft and fresh requested access after consent, then '/scopes/scope-alpha/channels/reg-alpha', ); }); - -it('preserves an explicitly cleared default skill after cancelled consent without binding', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('linked-default'); - fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('Select a skill')).toBeInTheDocument(); - expect(detailReads()).toHaveLength(1); - expect(screen.getByText('Service access needed')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); - expect(screen.getByRole('button', { name: 'Bind bot' })).toBeEnabled(); -}); - -it('removes a deleted service from the restored Bind draft, count and submission', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - await screen.findByText('Service access needed'); - await chooseSupport(); - fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const normalFetch = fetchMock.getMockImplementation(); - if (!normalFetch) throw new Error('Missing request fixture'); - fetchMock.mockImplementation((input, init) => - String(input).endsWith('/user-services') - ? Promise.resolve( - response({ - services: inventory.filter((item) => item.id !== 'us-github'), - }), - ) - : normalFetch(input, init), - ); - mount(); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect( - screen.queryByRole('checkbox', { name: /GitHub|us-github/ }), - ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(screen.getByText('2 selected')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); - fireEvent.click(screen.getByRole('button', { name: 'Bind bot' })); - await screen.findByText('Confirming your changes...'); - expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ - nyx_channel_bot_id: 'bot-alpha', - skill_name: 'support', - authorization_mode: 'explicit_service_allowlist', - service_ids: ['us-llm', 'us-ornn'], - }); -}); - -it('isolates bind drafts by bot and from edit registrations even when their raw IDs coincide', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(bindHref('reg-alpha')); - await screen.findByText('Service access needed'); - await chooseSupport(); - fireEvent.click(screen.getByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const other = mount(bindHref('bot-beta')); - await screen.findByText('linked-default'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); - expect( - await screen.findByRole('checkbox', { name: /GitHub/ }), - ).not.toBeChecked(); - other.unmount(); - const edit = mount('/scopes/scope-alpha/channels/reg-alpha/edit'); - await screen.findByText('saved-skill'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); - expect( - await screen.findByRole('checkbox', { name: /GitHub/ }), - ).not.toBeChecked(); - edit.unmount(); - mount(bindHref('reg-alpha')); - await screen.findByText(/Your changes have been kept/); - expect(screen.getByText('support', { exact: true })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); - expect(writes()).toHaveLength(0); -}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index 771e24497..add475648 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx @@ -17,8 +17,6 @@ import { channelConfiguration, channelsApi, } from '@/shared/api/channelsApi'; -import { NyxIDAuthClient } from '@/shared/auth/client'; -import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { t } from '@/shared/i18n/messages'; import { history } from '@/shared/navigation/history'; import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; @@ -32,6 +30,7 @@ import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; import ChannelServiceAccessNotice from './ChannelServiceAccessNotice'; import ChannelServicePicker from './ChannelServicePicker'; import ChannelSkillField from './ChannelSkillField'; +import ChannelSkillServices from './ChannelSkillServices'; import { channelConnectionCss } from './connectionStyles'; import { ChannelBadge, @@ -43,14 +42,8 @@ import { channelKeys, useChannelDetail, useChannelRegistrations, - useChannelServiceAccess, + useChannelServices, } from './queries'; -import { - type ChannelAccessDraftTarget, - clearChannelAccessDraft, - readChannelAccessDraft, - saveChannelAccessDraft, -} from './serviceAccessDraft'; import { channelsCss } from './styles'; type Target = @@ -76,13 +69,6 @@ export default function ChannelConfigurationPage({ readonly requestedServiceIds?: readonly string[]; }) { const editing = Boolean(target.registrationId); - const draftTarget = React.useMemo( - () => - target.botId !== undefined - ? { kind: 'bind', botId: target.botId } - : { kind: 'edit', registrationId: target.registrationId }, - [target.botId, target.registrationId], - ); const list = useChannelRegistrations(scopeId, !editing); const detail = useChannelDetail(scopeId, target.registrationId ?? ''); const query = editing ? detail : list; @@ -169,7 +155,7 @@ export default function ChannelConfigurationPage({ void> >; }) { - const editing = draftTarget.kind === 'edit'; const baseline = channelConfiguration(initial); - const [restoredDraft] = React.useState(() => - readChannelAccessDraft(scopeId, draftTarget), - ); - const [reviewPending, setReviewPending] = React.useState(false); - const reviewLeaving = React.useRef(false); - const reviewLock = React.useRef(false); const requestedIds = [ ...new Set( requestedServiceIds @@ -228,10 +207,8 @@ function ConfigurationForm({ ].slice(0, 20); const [initialSkillId] = React.useState(editing ? undefined : defaultSkillId); // Undefined means the link's default has not been resolved or overridden yet. - const [skillName, setSkillName] = React.useState( - () => - restoredDraft?.skillName ?? - (initialSkillId ? undefined : (initial.skill?.name ?? '')), + const [skillName, setSkillName] = React.useState(() => + initialSkillId ? undefined : (initial.skill?.name ?? ''), ); const skillReady = skillName !== undefined; const defaultSkill = useQuery({ @@ -259,11 +236,9 @@ function ConfigurationForm({ defaultSkill.data, ]); const [chosenServiceIds, setServiceIds] = React.useState( - restoredDraft?.serviceIds ?? baseline?.serviceIds ?? [], - ); - const [label, setLabel] = React.useState( - restoredDraft?.label ?? initial.label ?? '', + baseline?.serviceIds ?? [], ); + const [label, setLabel] = React.useState(initial.label ?? ''); const [savedLabel, setSavedLabel] = React.useState(initial.label ?? ''); const [receipt, setReceipt] = React.useState(null); const [submitted, setSubmitted] = React.useState( @@ -278,31 +253,16 @@ function ConfigurationForm({ const completed = React.useRef(false); const mounted = React.useRef(true); const labelId = React.useId(); - const services = useChannelServiceAccess(scopeId); - React.useEffect(() => { - if (restoredDraft) clearChannelAccessDraft(scopeId, draftTarget); - }, [restoredDraft, scopeId, draftTarget]); - React.useEffect(() => { - const resume = (event: PageTransitionEvent) => { - if (!event.persisted || !reviewLeaving.current) return; - reviewLeaving.current = false; - reviewLock.current = false; - setReviewPending(false); - clearChannelAccessDraft(scopeId, draftTarget); - void services.refetch(); - }; - window.addEventListener('pageshow', resume); - return () => window.removeEventListener('pageshow', resume); - }, [services.refetch, scopeId, draftTarget]); + const services = useChannelServices(scopeId); const availableServices = (services.data ?? []).filter( (service) => service.active && service.allowed, ); - const hasFreshServiceAccess = + const hasFreshServices = services.isFetchedAfterMount && services.isSuccess && !services.isFetching; React.useEffect(() => { - if (!hasFreshServiceAccess) return; - // Only confirmed access changes remove draft choices. A failed or pending - // refresh must not erase them, and later reauthorization must not reselect them. + if (!hasFreshServices) return; + // Only a successful inventory refresh removes unavailable choices. Failed + // requests preserve edits; reactivated services require a new selection. const availableIds = new Set( services.data ?.filter((service) => service.active && service.allowed) @@ -312,7 +272,7 @@ function ConfigurationForm({ const remaining = selected.filter((id) => availableIds.has(id)); return remaining.length === selected.length ? selected : remaining; }); - }, [hasFreshServiceAccess, services.data]); + }, [hasFreshServices, services.data]); const requiredServices = availableServices.filter((service) => requiredServiceSlugs.some((slug) => service.slug === slug), ); @@ -324,14 +284,13 @@ function ConfigurationForm({ ...new Set([ ...chosenServiceIds.filter( (id) => - !hasFreshServiceAccess || + !hasFreshServices || availableServices.some((service) => service.id === id), ), ...requiredIds, ]), ]; - const servicesReady = - hasFreshServiceAccess && missingRequiredSlugs.length === 0; + const servicesReady = hasFreshServices && missingRequiredSlugs.length === 0; const toast = useConsoleToast(); const client = useQueryClient(); const listHref = buildWorkflowActivitySectionHref(scopeId, 'channels'); @@ -349,7 +308,7 @@ function ConfigurationForm({ const dirty = editing ? configDirty || labelDirty : Boolean(skillName || chosenServiceIds.length); - const busy = submitting || Boolean(receipt) || uncertain || reviewPending; + const busy = submitting || Boolean(receipt) || uncertain; React.useEffect(() => { mounted.current = true; return () => { @@ -372,7 +331,6 @@ function ConfigurationForm({ React.useEffect(() => { if (!dirty || receipt || uncertain) return; const warn = (event: BeforeUnloadEvent) => { - if (reviewLeaving.current) return; event.preventDefault(); event.returnValue = ''; }; @@ -430,53 +388,13 @@ function ConfigurationForm({ completed.current = true; client.setQueryData(channelKeys.detail(scopeId, actual.id), actual); void client.invalidateQueries({ queryKey: channelKeys.list(scopeId) }); - clearChannelAccessDraft(scopeId, draftTarget); toast.success( editing ? t('channels.edit.saved', 'Channel changes saved.') : t('channels.bind.success', 'Bot bound successfully.'), ); history.replace(buildChannelDetailsHref(scopeId, actual.id)); - }, [ - observation.data, - submitted, - initial, - editing, - client, - scopeId, - toast, - draftTarget, - ]); - - async function reviewServiceAccess() { - if (busy || reviewLock.current || !skillReady) return; - reviewLock.current = true; - setReviewPending(true); - try { - // Persist before navigation. If storage fails, keep the editor open. - saveChannelAccessDraft(scopeId, draftTarget, { - label, - skillName: skillName ?? '', - serviceIds, - }); - reviewLeaving.current = true; - await new NyxIDAuthClient(getNyxIDRuntimeConfig()).loginWithRedirect({ - flow: 'serviceAccessReview', - returnTo: `${window.location.pathname}${window.location.search}${window.location.hash}`, - }); - } catch { - reviewLeaving.current = false; - reviewLock.current = false; - setReviewPending(false); - clearChannelAccessDraft(scopeId, draftTarget); - toast.error( - t( - 'channels.access.startFailed', - 'Could not open NyxID. Your changes are still here. Try again.', - ), - ); - } - } + }, [observation.data, submitted, initial, editing, client, scopeId, toast]); async function save(event: React.FormEvent) { event.preventDefault(); @@ -535,7 +453,6 @@ function ConfigurationForm({ void client.invalidateQueries({ queryKey: channelKeys.detail(scopeId, initial.id ?? ''), }); - clearChannelAccessDraft(scopeId, draftTarget); toast.success(t('channels.edit.saved', 'Channel changes saved.')); history.replace(returnHref); } @@ -661,11 +578,6 @@ function ConfigurationForm({ ) : null} void reviewServiceAccess(), - pending: reviewPending, - disabled: busy || !skillReady, - }} accessNotice={ services.isFetchedAfterMount && !services.isFetching && @@ -673,7 +585,6 @@ function ConfigurationForm({ ) : undefined } @@ -691,6 +602,21 @@ function ConfigurationForm({ retry={() => void services.refetch()} editing={editing} replacesDefaults={baseline?.authorizationMode === 'nyxid_default'} + suggestions={ + + setServiceIds((ids) => [...new Set([...ids, id])]) + } + refreshAccess={() => void services.refetch()} + /> + } /> {failure ? (

@@ -748,7 +674,6 @@ function ConfigurationForm({ onCancel={() => setLeaveTarget(null)} onOk={() => { if (leaveTarget) { - clearChannelAccessDraft(scopeId, draftTarget); history.push(leaveTarget); } setLeaveTarget(null); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx index 4c8c6284b..637377c05 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx @@ -59,7 +59,9 @@ const linkedSkill = (id = skillId) => ({ }); const detailReads = () => fetchMock.mock.calls.filter(([input]) => - String(input).includes('/api/v1/skills/'), + [skillId, secondSkillId].some((id) => + String(input).endsWith(`/skills/${id}`), + ), ); const writes = () => fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); @@ -89,6 +91,14 @@ beforeEach(() => { return response(linkedSkill()); if (String(input).endsWith(`/skills/${secondSkillId}`)) return response(linkedSkill(secondSkillId)); + if (String(input).startsWith('/api/skills/service-recommendations?')) + return response({ + skillName: new URL( + String(input), + 'https://console.test', + ).searchParams.get('skillName'), + suggestions: [], + }); if (String(input).endsWith('/user-services')) return response({ services: [ diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index ea3d06afd..9bd06b62c 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -99,6 +99,14 @@ const selectedServiceIds = [ 'user-service-ornn', ]; function catalogue(input: RequestInfo | URL, services = serviceCatalogue) { + if (String(input).startsWith('/api/skills/service-recommendations?')) + return response({ + skillName: new URL( + String(input), + 'https://console.test', + ).searchParams.get('skillName'), + suggestions: [], + }); if (String(input).includes('/skill-search')) return response({ data: { @@ -442,23 +450,12 @@ it('locks required services through individual, bulk and filtered deselection an it.each([ ['missing', 'ornn-api'], ['inactive', 'chrono-llm-public'], - ['unauthorized', 'ornn-api'], + ['account access denied', 'ornn-api'], ['similar slug', 'chrono-llm-public'], ])('blocks submission with a %s required service and recovers on retry', async (condition, slug) => { jest.useFakeTimers(); try { let restored = false; - const unavailableId = serviceCatalogue.find( - (service) => service.slug === slug, - )?.id; - if (condition === 'unauthorized') - persistAuthSession( - createNyxIDServiceSession({ - allowed_service_ids: selectedServiceIds.filter( - (id) => id !== unavailableId, - ), - }), - ); const unavailableServices = serviceCatalogue .filter((service) => condition !== 'missing' || service.slug !== slug) .map((service) => @@ -466,6 +463,10 @@ it.each([ ? service : { ...service, + credential_source: + condition === 'account access denied' + ? { type: 'org', allowed: false } + : service.credential_source, is_active: condition !== 'inactive', slug: condition === 'similar slug' ? `${slug}-other` : slug, label: slug, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx index 17bccdde4..82b3e5f8e 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccess.test.tsx @@ -1,12 +1,5 @@ -import { - act, - cleanup, - fireEvent, - screen, - waitFor, -} from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; -import { NyxIDAuthClient } from '@/shared/auth/client'; import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; @@ -116,170 +109,46 @@ beforeEach(() => { }); }); -it('shows exact missing services from the link without selecting or granting them, even when another account has the same slug', async () => { - grant([...baseIds, 'us-firecrawl-other']); +it('lets Edit select active services omitted at login and saves exact IDs without an access review', async () => { + grant([]); mount( `${editHref}&requiredServiceId=us-firecrawl&requiredServiceId=unknown-service`, ); - await screen.findByText('Service access needed'); - expect(screen.getAllByText('Firecrawl', { exact: true })).toHaveLength(1); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - expect(screen.getByText('Service not found')).toBeInTheDocument(); - expect( - screen.getByText(/choose Customize under Service access/), - ).toBeInTheDocument(); - expect( - screen.queryByRole('checkbox', { name: /^Firecrawl/ }), - ).not.toBeInTheDocument(); + const firecrawl = await screen.findByRole('checkbox', { name: /^Firecrawl/ }); + expect(firecrawl).toBeEnabled(); + expect(firecrawl).not.toBeChecked(); expect( screen.getByRole('checkbox', { name: /Other Firecrawl account/ }), ).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); expect(screen.getByText('3 selected')).toBeInTheDocument(); - expect(writes()).toHaveLength(0); -}); - -it('restores the draft after full consent, checks fresh grants and requires selecting the newly available service before saving', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - fireEvent.click(await screen.findByRole('checkbox', { name: /GitHub/ })); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(review).toHaveBeenCalledWith({ - flow: 'serviceAccessReview', - returnTo: editHref, - }), - ); - expect(writes()).toHaveLength(0); - const leaving = new Event('beforeunload', { cancelable: true }); - window.dispatchEvent(leaving); - expect(leaving.defaultPrevented).toBe(false); - first.unmount(); - grant([...baseIds, 'us-firecrawl']); - mount(); - expect(await screen.findByLabelText('Label')).toHaveValue('Edited label'); + expect(screen.getByText('Service not found')).toBeInTheDocument(); expect( - await screen.findByText(/Your changes have been kept/), - ).toBeInTheDocument(); - expect(screen.getByText('Lark Bot API')).toBeInTheDocument(); - expect(screen.queryByText('Service access checked')).not.toBeInTheDocument(); - const firecrawl = screen.getByRole('checkbox', { name: /Firecrawl/ }); - expect(firecrawl).not.toBeChecked(); - expect(screen.getByRole('checkbox', { name: /GitHub/ })).not.toBeChecked(); + screen.queryByRole('button', { name: /Manage service access/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/choose Customize under Service access/), + ).not.toBeInTheDocument(); + expect(writes()).toHaveLength(0); fireEvent.click(firecrawl); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await screen.findByText('Confirming your changes...'); - const post = writes().find(([, init]) => init?.method === 'POST'); - expect(JSON.parse(String(post?.[1]?.body))).toMatchObject({ - service_ids: ['us-firecrawl', 'us-llm', 'us-ornn'], + expect(writes()).toHaveLength(1); + expect(writes()[0][0]).toBe('/api/channels/registrations/reg-alpha'); + expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-firecrawl', 'us-github', 'us-llm', 'us-ornn'], }); expect(mockToast.success).not.toHaveBeenCalled(); }); -it('retains missing access and draft after cancellation, and recovers from a failed review launch', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - fireEvent.click( - await screen.findByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - mount(); - expect( - await screen.findByText(/Your changes have been kept/), - ).toBeInTheDocument(); - expect(screen.getByText('Service access needed')).toBeInTheDocument(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - review.mockRejectedValueOnce(new Error('TEST_PRIVATE_ERROR')); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => - expect(mockToast.error).toHaveBeenCalledWith( - 'Could not open NyxID. Your changes are still here. Try again.', - ), - ); - expect( - screen.getByRole('button', { name: /Manage service access/ }), - ).toBeEnabled(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - const leaving = new Event('beforeunload', { cancelable: true }); - window.dispatchEvent(leaving); - expect(leaving.defaultPrevented).toBe(true); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(3)); - expect(writes()).toHaveLength(0); -}); - -it('keeps the editor open when draft storage is unavailable and does not restore a draft for another account', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); - const first = mount(); - fireEvent.change(await screen.findByLabelText('Label'), { - target: { value: 'Edited label' }, - }); - const storage = jest - .spyOn(Storage.prototype, 'setItem') - .mockImplementation(() => { - throw new Error('Storage blocked'); - }); - fireEvent.click( - await screen.findByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(mockToast.error).toHaveBeenCalled()); - expect(review).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - storage.mockRestore(); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - first.unmount(); - const other = createNyxIDServiceSession({ - sub: 'user-other', - allowed_service_ids: baseIds, - }); - persistAuthSession({ ...other, user: { sub: 'user-other' } }); - mount(); - expect(await screen.findByLabelText('Label')).toHaveValue('Support bot'); - await screen.findByText('Service access needed'); - expect( - screen.queryByText(/Your changes have been kept/), - ).not.toBeInTheDocument(); -}); - -it('preserves choices on failed history refresh, then removes revoked selections without reselecting them on later authorization', async () => { - const review = jest - .spyOn(NyxIDAuthClient.prototype, 'loginWithRedirect') - .mockResolvedValue(); +it('preserves edits during failed inventory refresh, then removes inactive choices without reselecting them on reactivation', async () => { const { queryClient } = mount(); fireEvent.change(await screen.findByLabelText('Label'), { target: { value: 'Edited label' }, }); - await screen.findByText('Service access needed'); - fireEvent.click( - screen.getByRole('button', { name: /Manage service access/ }), - ); - await waitFor(() => expect(review).toHaveBeenCalledTimes(1)); - const draftKeys = () => - Object.keys(window.sessionStorage).filter((key) => - key.startsWith('aevatar:channel-access-draft:'), - ); - expect(draftKeys()).toHaveLength(1); + await screen.findByRole('checkbox', { name: /GitHub/ }); const normalFetch = fetchMock.getMockImplementation(); if (!normalFetch) throw new Error('Missing request fixture'); fetchMock.mockImplementation((input, init) => { @@ -287,34 +156,34 @@ it('preserves choices on failed history refresh, then removes revoked selections return Promise.resolve({ ok: false, status: 503 } as Response); return normalFetch(input, init); }); - grant(['us-ornn', 'us-llm', 'us-firecrawl']); - act(() => { - const restored = new Event('pageshow'); - Object.defineProperty(restored, 'persisted', { value: true }); - window.dispatchEvent(restored); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: channelKeys.services('scope-alpha'), + }); }); await screen.findByText( 'Could not load your services. Try again before saving.', ); expect(screen.getByText('3 selected')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); - fetchMock.mockImplementation(normalFetch); + fetchMock.mockImplementation((input, init) => + String(input).endsWith('/user-services') + ? Promise.resolve( + response({ + services: inventory.map((item) => + item.id === 'us-github' ? { ...item, is_active: false } : item, + ), + }), + ) + : normalFetch(input, init), + ); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); await screen.findByText('2 selected'); expect( screen.queryByRole('checkbox', { name: /GitHub/ }), ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(draftKeys()).toHaveLength(0); expect(screen.getByLabelText('Label')).toHaveValue('Edited label'); - expect(screen.getByRole('checkbox', { name: /Firecrawl/ })).not.toBeChecked(); - expect( - screen.getByRole('button', { name: /Manage service access/ }), - ).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); - grant([...baseIds, 'us-firecrawl']); + fetchMock.mockImplementation(normalFetch); await act(async () => { await queryClient.invalidateQueries({ queryKey: channelKeys.services('scope-alpha'), @@ -335,8 +204,8 @@ it('preserves choices on failed history refresh, then removes revoked selections ]); }); -it('removes saved missing, inactive and unauthorized services on initial load while retaining URL access hints', async () => { - grant(baseIds.filter((id) => id !== 'us-github').concat('us-firecrawl')); +it('removes saved missing and inactive services while preserving active selections outside login consent', async () => { + grant([]); const normalFetch = fetchMock.getMockImplementation(); if (!normalFetch) throw new Error('Missing request fixture'); const saved = { @@ -359,22 +228,19 @@ it('removes saved missing, inactive and unauthorized services on initial load wh return normalFetch(input, init); }); mount(); - await screen.findByText('Service access needed'); + await screen.findByText('Requested services unavailable'); expect(screen.getByText('Firecrawl', { exact: true })).toBeInTheDocument(); expect( - screen.queryByRole('checkbox', { name: /GitHub|Firecrawl|us-deleted/ }), + screen.queryByRole('checkbox', { name: /^Firecrawl|us-deleted/ }), ).not.toBeInTheDocument(); - expect( - screen.queryByText('Unavailable', { exact: true }), - ).not.toBeInTheDocument(); - expect(screen.getByText('2 selected')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /GitHub/ })).toBeChecked(); + expect(screen.getByText('3 selected')).toBeInTheDocument(); expect(writes()).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); await screen.findByText('Confirming your changes...'); expect(JSON.parse(String(writes()[0][1]?.body)).service_ids).toEqual([ + 'us-github', 'us-llm', 'us-ornn', ]); }); - -afterEach(cleanup); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx index 30c594f26..44fd2e430 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServiceAccessNotice.tsx @@ -6,11 +6,9 @@ import { t } from '@/shared/i18n/messages'; export default function ChannelServiceAccessNotice({ requestedIds, services, - restored, }: { readonly requestedIds: readonly string[]; readonly services: readonly ChannelServiceChoice[]; - readonly restored: boolean; }) { const missing = requestedIds.filter( (id) => @@ -18,73 +16,63 @@ export default function ChannelServiceAccessNotice({ (service) => service.id === id && service.active && service.allowed, ), ); - const draftNotice = restored ? ( -

- {t( - 'channels.access.restored', - 'Your changes have been kept. Review and save.', - )} -

- ) : null; - if (!missing.length) return draftNotice; + if (!missing.length) return null; return ( - <> - {draftNotice} -
-
-
-

- {t( - 'channels.access.instructions', - 'In NyxID, choose Customize under Service access. Keep the services you still use selected and add the services below, then choose Allow.', - )} -

-
    - {missing.map((id) => { - const service = services.find((item) => item.id === id); - return ( -
  • - - - {service?.label ?? - t('channels.access.unknown', 'Service not found')} - - {service ? ( - - {service.slug} - - ) : ( -
    - - {t( - 'channels.access.requestedIdentity', - 'Requested service ID', - )} - - {id} -
    - )} -
    - - {!service || !service.active - ? t( - 'channels.access.unavailable', - 'Check availability in NyxID', - ) - : t('channels.access.notAuthorized', 'Access needed')} - -
  • - ); - })} -
+
+
+
- +

+ {t( + 'channels.access.instructions', + 'Check these services in NyxID. Active services your account can use can be selected here.', + )} +

+
    + {missing.map((id) => { + const service = services.find((item) => item.id === id); + return ( +
  • + + + {service?.label ?? + t('channels.access.unknown', 'Service not found')} + + {service ? ( + {service.slug} + ) : ( +
    + + {t( + 'channels.access.requestedIdentity', + 'Requested service ID', + )} + + {id} +
    + )} +
    + + {!service || !service.active + ? t( + 'channels.access.unavailable', + 'Check availability in NyxID', + ) + : t( + 'channels.access.notAuthorized', + 'Account access unavailable', + )} + +
  • + ); + })} +
+
); } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx index e413c97e0..50788dbff 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx @@ -1,8 +1,4 @@ -import { - ExportOutlined, - SafetyCertificateOutlined, - SearchOutlined, -} from '@ant-design/icons'; +import { SearchOutlined } from '@ant-design/icons'; import { Button, Checkbox, Input } from 'antd'; import * as React from 'react'; import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; @@ -11,7 +7,6 @@ import { AevatarContentSkeleton } from '@/shared/ui/AevatarContentSkeleton'; export default function ChannelServicePicker({ services, - accessAction, accessNotice, suggestedIds = [], selectedIds, @@ -25,12 +20,8 @@ export default function ChannelServicePicker({ retry, editing = false, replacesDefaults = false, + suggestions, }: { - readonly accessAction?: { - readonly onReview: () => void; - readonly pending: boolean; - readonly disabled: boolean; - }; readonly accessNotice?: React.ReactNode; readonly suggestedIds?: readonly string[]; readonly services: readonly ChannelServiceChoice[]; @@ -45,6 +36,7 @@ export default function ChannelServicePicker({ readonly retry: () => void; readonly editing?: boolean; readonly replacesDefaults?: boolean; + readonly suggestions?: React.ReactNode; }) { const [search, setSearch] = React.useState(''); const term = search.trim().toLocaleLowerCase(); @@ -67,37 +59,20 @@ export default function ChannelServicePicker({

{t('channels.connect.services', 'Services')}

-
- - {t('channels.connect.selected', '{count} selected', { - count: selectedIds.length, - })} - - {accessAction ? ( - - ) : null} -
+ + {t('channels.connect.selected', '{count} selected', { + count: selectedIds.length, + })} +

- {accessAction - ? t( - 'channels.access.help', - 'Missing a service? Click Manage service access to authorize it.', - ) - : t( - 'channels.connect.servicesHelp', - 'Only services available through your current NyxID authorization are shown.', - )} + {t( + 'channels.connect.servicesHelp', + 'Choose active services from your NyxID account, including services you did not select when signing in.', + )}

{accessNotice} + {suggestions} {loading ? ( ) : ( diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx new file mode 100644 index 000000000..9fa27f9d8 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx @@ -0,0 +1,344 @@ +import { + act, + fireEvent, + screen, + waitFor, + within, +} from '@testing-library/react'; +import * as React from 'react'; +import { authFetch } from '@/shared/auth/fetch'; +import { persistAuthSession } from '@/shared/auth/session'; +import { createNyxIDServiceSession } from '../../../../tests/fixtures/nyxidServiceSession'; +import { renderWithQueryClient } from '../../../../tests/reactQueryTestUtils'; +import ChannelConfigurationPage from './ChannelConfigurationPage'; + +jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); +jest.mock('@/shared/studio/api', () => ({ + studioApi: { + getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }), + }, +})); + +const fetchMock = jest.mocked(authFetch); +const response = (value: unknown, status = 200) => + ({ ok: status === 200, status, json: async () => value }) as Response; +const personal = (id: string, slug: string, label = slug) => ({ + id, + slug, + label, + is_active: true, + credential_source: { type: 'personal' }, +}); +const inventory = [ + personal('us-ornn', 'ornn-api', 'Ornn'), + personal('us-llm', 'chrono-llm-public', 'LLM'), + personal('us-manual', 'manual-only', 'Manual service'), + personal('us-personal', 'api-github', 'Personal GitHub'), + { + ...personal('us-org', 'api-github', 'Team GitHub'), + credential_source: { type: 'org', org_name: 'Acme', allowed: true }, + }, + personal('us-linear', 'linear', 'Linear'), + { ...personal('us-inactive', 'inactive-service'), is_active: false }, + { + ...personal('us-owner', 'owner-service'), + credential_source: { type: 'org', org_name: 'Other team', allowed: false }, + }, +]; +const allowedIds = ['us-ornn', 'us-llm', 'us-manual', 'us-personal', 'us-org']; +const registration = { + id: 'reg-alpha', + nyx_channel_bot_id: 'bot-alpha', + platform: 'lark', + label: 'Support bot', + owned: true, + binding_status: 'bound', + availability_status: 'available', + nyx_status: 'active', + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-manual', 'us-ornn', 'us-llm'], + state_version: 12, +}; +const instance = (id: string, slug: string, label: string) => ({ + id, + slug, + label, + active: true, + allowed: true, + source: 'personal', + organizationName: '', +}); +function suggestionsResponse(skillName = 'support', recovered = false) { + return { + skillName, + suggestions: + skillName === 'no-services' + ? [] + : [ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'linked', + instances: [ + instance('us-personal', 'api-github', 'Personal GitHub'), + { + ...instance('us-org', 'api-github', 'Team GitHub'), + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + { + slug: 'linear', + label: 'Linear', + evidence: 'mention', + instances: [instance('us-linear', 'linear', 'Linear')], + }, + { + slug: 'inactive-service', + label: 'Inactive service', + evidence: 'mention', + instances: [ + { + ...instance( + 'us-inactive', + 'inactive-service', + 'Inactive service', + ), + active: false, + }, + ], + }, + { + slug: 'owner-service', + label: 'Owner service', + evidence: 'mention', + instances: [ + { + ...instance('us-owner', 'owner-service', 'Owner service'), + allowed: false, + source: 'organization', + organizationName: 'Other team', + }, + ], + }, + { + slug: 'api-drive', + label: 'Drive', + evidence: 'catalog', + instances: [], + }, + { + slug: 'slack', + label: 'Slack', + evidence: 'mention', + instances: recovered + ? [instance('us-slack', 'slack', 'Connected Slack')] + : [], + }, + ], + }; +} + +async function serve(input: RequestInfo | URL, init?: RequestInit) { + const url = String(input); + if (init?.method === 'POST') + return response({ error: 'insecure_webhook_base_url' }, 400); + if (url.endsWith('/user-services')) return response({ services: inventory }); + if (url.startsWith('/api/skills/service-recommendations?')) + return response( + suggestionsResponse( + new URL(url, 'https://console.test').searchParams.get('skillName') ?? + '', + ), + ); + if (url.includes('/skill-search')) + return response({ + data: { + items: ['support', 'slow-skill', 'no-services'].map((name) => ({ + guid: `guid-${name}`, + name, + })), + meta: { hasMore: false }, + }, + }); + if (url === '/api/channels/registrations?scope=all') + return response([registration]); + if (url === '/api/channels/registrations/reg-alpha') + return response(registration); + throw new Error(`Unexpected request: ${url}`); +} + +beforeEach(() => { + fetchMock.mockReset().mockImplementation(serve); + persistAuthSession( + createNyxIDServiceSession({ allowed_service_ids: allowedIds }), + ); +}); + +function renderForm() { + return renderWithQueryClient( + , + ); +} +function suggestions(skill = 'support') { + return screen.getByRole('region', { name: `Suggested for ${skill}` }); +} +async function chooseSkill(name: string) { + fireEvent.mouseDown(await screen.findByRole('combobox')); + fireEvent.click( + await screen.findByText(name, { + selector: '.channels__skill-option strong', + }), + ); +} +const writes = () => + fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); + +it('displays backend recommendations and access gaps, then saves only the explicitly selected UserService instance', async () => { + renderForm(); + await screen.findByRole('button', { name: 'Select Team GitHub' }); + const related = within(suggestions()); + expect( + related.getByText('Linked to this skill in Ornn.'), + ).toBeInTheDocument(); + expect( + related.getByText('The service catalog recommends this skill.'), + ).toBeInTheDocument(); + expect(related.getByText('Slack')).toBeInTheDocument(); + expect(related.getAllByText(/may be needed for some tasks/)).not.toHaveLength( + 0, + ); + expect(related.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); + expect(related.getByText(/Inactive —/)).toBeInTheDocument(); + expect(related.getByText(/Access unavailable —/)).toBeInTheDocument(); + expect(related.getAllByText(/No connection found/)).toHaveLength(2); + expect( + related.queryByRole('button', { name: 'Select Owner service' }), + ).not.toBeInTheDocument(); + expect( + related.getByRole('link', { name: /Manage connections/ }), + ).toHaveAttribute('href', 'https://nyx.chrono-ai.fun/services'); + expect( + related.queryByRole('link', { name: /Review service access/ }), + ).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some(([url]) => + /\/skills\/.*\/json|\/catalog\?/.test(String(url)), + ), + ).toBe(false); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); + expect(writes()).toHaveLength(0); + fireEvent.click(related.getByRole('button', { name: 'Select Linear' })); + fireEvent.click(related.getByRole('button', { name: 'Select Team GitHub' })); + expect(related.getAllByText('Selected')).toHaveLength(2); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Team GitHub/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByRole('alert'); + expect(writes()).toHaveLength(1); + expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ + skill_name: 'support', + authorization_mode: 'explicit_service_allowlist', + service_ids: ['us-linear', 'us-llm', 'us-manual', 'us-org', 'us-ornn'], + }); +}); + +it('updates suggestions on skill changes and ignores late results while retaining manual choices after clearing', async () => { + let complete!: (value: Response) => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + let pendingSignal: AbortSignal | null | undefined; + fetchMock.mockImplementation((input, init) => { + if ( + String(input).endsWith('/service-recommendations?skillName=slow-skill') + ) { + pendingSignal = init?.signal; + return pending; + } + return serve(input, init); + }); + renderForm(); + fireEvent.click( + await screen.findByRole('button', { name: 'Select Personal GitHub' }), + ); + await chooseSkill('slow-skill'); + await waitFor(() => expect(pendingSignal).toBeTruthy()); + expect( + within(suggestions('slow-skill')).getByText('Finding related services...'), + ).toBeInTheDocument(); + expect( + within(suggestions('slow-skill')).queryByText('GitHub'), + ).not.toBeInTheDocument(); + await chooseSkill('no-services'); + await screen.findByText(/No related services identified/); + expect(pendingSignal?.aborted).toBe(true); + await act(async () => complete(response(suggestionsResponse('slow-skill')))); + expect( + within(suggestions('no-services')).queryByText('Slack'), + ).not.toBeInTheDocument(); + fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); + expect( + screen.queryByRole('region', { name: /Suggested for/ }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Manual service/ }), + ).toBeChecked(); + expect(writes()).toHaveLength(0); +}); + +it('keeps manual selection usable on discovery failure and refreshes connections and authorization on retry', async () => { + let recovered = false; + fetchMock.mockImplementation((input, init) => { + if (!recovered && String(input).includes('/service-recommendations?')) + return Promise.resolve( + response({ message: 'PRIVATE_SERVER_DETAIL' }, 503), + ); + if (recovered && String(input).includes('/service-recommendations?')) + return Promise.resolve(response(suggestionsResponse('support', true))); + if (recovered && String(input).endsWith('/user-services')) + return Promise.resolve( + response({ + services: [ + ...inventory, + personal('us-slack', 'slack', 'Connected Slack'), + ], + }), + ); + return serve(input, init); + }); + renderForm(); + await screen.findByText(/Could not identify related services/); + expect(document.body).not.toHaveTextContent('PRIVATE_SERVER_DETAIL'); + fireEvent.click(screen.getByRole('checkbox', { name: /Personal GitHub/ })); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); + recovered = true; + persistAuthSession( + createNyxIDServiceSession({ + allowed_service_ids: [...allowedIds, 'us-slack', 'us-linear'], + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Refresh suggestions' })); + await screen.findByRole('button', { name: 'Select Connected Slack' }); + expect(screen.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); + expect( + screen.queryByText(/Could not identify related services/), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /Personal GitHub/ }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /Connected Slack/ }), + ).not.toBeChecked(); + expect(writes()).toHaveLength(0); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx new file mode 100644 index 000000000..32b68322d --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx @@ -0,0 +1,229 @@ +import { useQuery } from '@tanstack/react-query'; +import { Button } from 'antd'; +import * as React from 'react'; +import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; +import { getChannelSkillServices } from '@/shared/api/channelSkillServicesApi'; +import { t } from '@/shared/i18n/messages'; +import { AevatarLoadingDots } from '@/shared/ui/AevatarLoading'; +import { channelKeys } from './queries'; + +export default function ChannelSkillServices({ + scopeId, + skillName, + services, + selectedIds, + checkingAccess, + accessFailed, + disabled, + onSelect, + refreshAccess, +}: { + readonly scopeId: string; + readonly skillName: string; + readonly services: readonly ChannelServiceChoice[]; + readonly selectedIds: readonly string[]; + readonly checkingAccess: boolean; + readonly accessFailed: boolean; + readonly disabled: boolean; + readonly onSelect: (id: string) => void; + readonly refreshAccess: () => void; +}) { + const titleId = React.useId(); + const query = useQuery({ + queryKey: channelKeys.skillServices(scopeId, skillName), + queryFn: ({ signal }) => getChannelSkillServices(skillName, signal), + enabled: Boolean(scopeId && skillName), + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + if (!skillName) return null; + const availableIds = new Set( + services + .filter((service) => service.active && service.allowed) + .map((service) => service.id), + ); + const refreshing = query.isFetching || checkingAccess; + return ( +
+
+

+ {t('channels.suggestions.title', 'Suggested for {skill}', { + skill: skillName, + })} +

+ +
+ {query.isPending || query.isFetching ? ( +

+ + {t('channels.suggestions.loading', 'Finding related services...')} +

+ ) : query.isError ? ( +

+ {t( + 'channels.suggestions.error', + 'Could not identify related services. Refresh to retry, or select services manually below.', + )} +

+ ) : ( + <> +

+ {t( + 'channels.suggestions.help', + 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', + )} +

+ {query.data.length ? ( +
    + {query.data.map((recommendation) => ( +
  • + {recommendation.label} + + {recommendation.slug} + +

    + {recommendation.evidence === 'linked' + ? t( + 'channels.suggestions.linked', + 'Linked to this skill in Ornn.', + ) + : recommendation.evidence === 'catalog' + ? t( + 'channels.suggestions.catalog', + 'The service catalog recommends this skill.', + ) + : t( + 'channels.suggestions.mention', + 'Mentioned in the skill description or instructions; may be needed for some tasks.', + )} +

    + {recommendation.instances.length ? ( + recommendation.instances.map((instance) => { + const selected = selectedIds.includes(instance.id); + const selectable = availableIds.has(instance.id); + return ( +
    +
    + + {instance.label} + + + {instance.source === 'personal' + ? t('channels.connect.personal', 'Personal') + : instance.source === 'organization' + ? instance.organizationName || + t( + 'channels.connect.organization', + 'Organization', + ) + : t( + 'channels.suggestions.unknownSource', + 'Unknown source', + )} + + + {checkingAccess + ? t( + 'channels.suggestions.checking', + 'Checking access...', + ) + : accessFailed + ? t( + 'channels.suggestions.accessError', + 'Could not check access. Refresh to retry.', + ) + : !instance.active + ? t( + 'channels.suggestions.inactive', + 'Inactive — manage this service in NyxID.', + ) + : !instance.allowed + ? t( + 'channels.suggestions.unavailable', + 'Access unavailable — check with the service owner.', + ) + : !selectable + ? t( + 'channels.suggestions.missingInstance', + 'No active connection found. Refresh or check this service in NyxID.', + ) + : !selected + ? t( + 'channels.suggestions.notSelected', + 'Not selected', + ) + : null} + +
    + {selected ? ( + + {t('channels.suggestions.selected', 'Selected')} + + ) : selectable && + instance.active && + instance.allowed ? ( + + ) : null} +
    + ); + }) + ) : ( +

    + {t( + 'channels.suggestions.notConnected', + 'No connection found in your account. Add this service in NyxID.', + )} +

    + )} +
  • + ))} +
+ ) : ( +

+ {t( + 'channels.suggestions.empty', + 'No related services identified. You can still select services manually below.', + )} +

+ )} + + )} + +
+ ); +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts index 8ae5dd1ba..dcc849730 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts @@ -22,8 +22,20 @@ export const channelConnectionCss = ` .channels__connection-form button.ant-input-password-icon { background: transparent; border: 0; cursor: pointer; padding: 4px; } .channels__connection-form button.ant-input-password-icon:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__services-heading { align-items: center; display: flex; gap: 16px; justify-content: space-between; } -.channels__services-actions > span { color: var(--wa-blue); font-size: 11px; } -.channels__services-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 12px; justify-content: flex-end; } +.channels__services-heading > span { color: var(--wa-blue); font-size: 11px; } +.channels__skill-services { background: var(--wa-subtle); border: 1px solid var(--channels-border); border-radius: var(--wa-radius); margin-top: 12px; padding: 12px; } +.channels__skill-services .channels__services-heading { align-items: flex-start; flex-wrap: wrap; gap: 8px; } +.channels__skill-services h3 { color: var(--wa-ink); flex: 1 1 160px; font-size: 12px; line-height: 20px; margin: 0; overflow-wrap: anywhere; } +.channels__suggestion-list { list-style: none; margin: 12px 0 0; max-height: 360px; overflow-y: auto; padding: 0; scrollbar-gutter: stable; } +.channels__suggestion { border-top: 1px solid var(--channels-border); color: var(--wa-ink); font-size: 12px; padding: 12px 0; overflow-wrap: anywhere; } +.channels__suggestion:first-child { border-top: 0; padding-top: 0; } +.channels__suggestion:last-child { padding-bottom: 0; } +.channels__suggestion-instance { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.channels__suggestion-instance > div { flex: 1 1 180px; min-width: 0; } +.channels__suggestion-selected { color: var(--wa-blue); font-size: 11px; } +.channels__suggestion-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; } +.channels__suggestion-links a { color: var(--wa-blue); font-size: 11px; line-height: 18px; } +.channels__suggestion-links a:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__access-notice { background: var(--wa-blue-bg); border: 1px solid var(--channels-border); border-left: 3px solid var(--wa-blue); border-radius: var(--wa-radius); color: var(--wa-ink); font-size: 12px; line-height: 20px; margin-top: 16px; padding: 16px; } .channels__access-notice--needed { background: var(--wa-amber-bg); border-left-color: var(--wa-amber); } .channels__access-notice-heading { align-items: center; display: flex; gap: 8px; } @@ -65,7 +77,6 @@ export const channelConnectionCss = ` .channels__bot-summary .channels__dot { display: none; } @media (max-width: 767px) { .channels__services-heading { align-items: flex-start; flex-wrap: wrap; } - .channels__services-actions { flex: 1 1 220px; } .channels__name-fields { gap: 0; grid-template-columns: 1fr; } .channels__main--connect .channels__content { padding-top: 20px; } .channels__connect-heading h1 { font-size: 24px; line-height: 32px; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts index 952fbd013..3b2737304 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts @@ -1,16 +1,18 @@ import { useQuery } from '@tanstack/react-query'; -import { listChannelServiceAccess } from '@/shared/api/channelServicesApi'; +import { listChannelServices } from '@/shared/api/channelServicesApi'; import { ChannelApiError, channelsApi } from '@/shared/api/channelsApi'; export const channelKeys = { detail: (scopeId: string, id: string) => ['channels', scopeId, 'detail', id] as const, services: (scopeId: string) => - ['channels', scopeId, 'service-access'] as const, + ['channels', scopeId, 'service-inventory'] as const, skills: (scopeId: string, search: string) => ['channels', scopeId, 'skills', search] as const, skill: (scopeId: string, id: string) => ['channels', scopeId, 'skill', id] as const, + skillServices: (scopeId: string, name: string) => + ['channels', scopeId, 'skill-services', name] as const, list: (scopeId: string) => ['channels', scopeId, 'registrations'] as const, }; @@ -58,11 +60,11 @@ export function useChannelDetail(scopeId: string, id: string) { }); } -export function useChannelServiceAccess(scopeId: string) { +export function useChannelServices(scopeId: string) { return useQuery({ ...queryOptions, queryKey: channelKeys.services(scopeId), - queryFn: ({ signal }) => listChannelServiceAccess(signal), + queryFn: ({ signal }) => listChannelServices(signal), enabled: Boolean(scopeId), refetchOnMount: 'always', }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts deleted file mode 100644 index 50f371a5b..000000000 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/serviceAccessDraft.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { loadRestorableAuthSession } from '@/shared/auth/session'; - -export type ChannelAccessDraftTarget = - | { readonly kind: 'bind'; readonly botId: string } - | { readonly kind: 'edit'; readonly registrationId: string }; - -export interface ChannelAccessDraft { - readonly label: string; - readonly skillName: string; - readonly serviceIds: readonly string[]; -} - -function storageKey(scopeId: string, target: ChannelAccessDraftTarget): string { - const subject = loadRestorableAuthSession()?.user.sub; - if (!subject) throw new Error('A signed-in account is required.'); - const id = target.kind === 'bind' ? target.botId : target.registrationId; - return `aevatar:channel-access-draft:${JSON.stringify([subject, scopeId, target.kind, id])}`; -} - -export function saveChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, - draft: ChannelAccessDraft, -): void { - window.sessionStorage.setItem( - storageKey(scopeId, target), - JSON.stringify({ ...draft, expiresAt: Date.now() + 60 * 60 * 1000 }), - ); -} - -export function clearChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, -): void { - try { - window.sessionStorage.removeItem(storageKey(scopeId, target)); - } catch { - // A blocked browser store must not prevent leaving or saving the editor. - } -} - -export function readChannelAccessDraft( - scopeId: string, - target: ChannelAccessDraftTarget, -): ChannelAccessDraft | null { - try { - const raw = window.sessionStorage.getItem(storageKey(scopeId, target)); - if (!raw) return null; - const draft: unknown = JSON.parse(raw); - if (!draft || typeof draft !== 'object') return null; - if ( - !('expiresAt' in draft) || - typeof draft.expiresAt !== 'number' || - draft.expiresAt <= Date.now() - ) { - clearChannelAccessDraft(scopeId, target); - return null; - } - if ( - !('label' in draft) || - typeof draft.label !== 'string' || - !('skillName' in draft) || - typeof draft.skillName !== 'string' || - !('serviceIds' in draft) || - !Array.isArray(draft.serviceIds) || - !draft.serviceIds.every((id): id is string => typeof id === 'string') - ) - return null; - return { - label: draft.label, - skillName: draft.skillName, - serviceIds: draft.serviceIds, - }; - } catch { - return null; - } -} diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts index ca02c947d..0b74c10f2 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.test.ts @@ -2,8 +2,8 @@ import { authFetch } from '@/shared/auth/fetch'; import { persistAuthSession } from '@/shared/auth/session'; import { createNyxIDServiceSession } from '../../../tests/fixtures/nyxidServiceSession'; import { - listChannelServiceAccess, listChannelServiceIdentities, + listChannelServices, } from './channelServicesApi'; jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); @@ -54,7 +54,7 @@ afterEach(() => { global.fetch = originalFetch; }); -it('includes authorized LLM services and matches exact UserService grants without requiring an MCP tool entry', async () => { +it('lists account services independently of login grants while preserving exact IDs and organization access', async () => { const session = createNyxIDServiceSession({ allowed_service_ids: [ 'us-work', @@ -108,19 +108,21 @@ it('includes authorized LLM services and matches exact UserService grants withou }), ); const signal = new AbortController().signal; - const result = await listChannelServiceAccess(signal); + const result = await listChannelServices(signal); expect( result .filter((service) => service.active && service.allowed) .map(({ id, label }) => ({ id, label })), ).toEqual([ { id: 'us-work', label: 'GitHub work' }, + { id: 'us-other-account-key', label: 'GitHub work' }, + { id: 'us-catalog-reference', label: 'GitHub work' }, { id: 'us-model', label: 'Chrono Public' }, { id: 'us-org', label: 'Google Drive' }, ]); expect( result.find((service) => service.id === 'us-other-account-key')?.allowed, - ).toBe(false); + ).toBe(true); expect(result.find((service) => service.id === 'us-viewer')?.allowed).toBe( false, ); @@ -140,7 +142,7 @@ it('includes authorized LLM services and matches exact UserService grants withou it.each([ true, false, -])('respects explicit allow_all_services=%s with an empty ID list', async (allowAll) => { +])('keeps active account services selectable with allow_all_services=%s and no login service selections', async (allowAll) => { persistAuthSession( createNyxIDServiceSession({ allow_all_services: allowAll, @@ -148,36 +150,21 @@ it.each([ }), ); fetchMock.mockResolvedValue(response({ services: [personal] })); - const result = await listChannelServiceAccess(); + const result = await listChannelServices(); expect(result.map(({ id, allowed }) => ({ id, allowed }))).toEqual([ - { id: 'us-work', allowed: allowAll }, + { id: 'us-work', allowed: true }, ]); }); -it.each([ - ['missing grant mode', { allow_all_services: undefined }], - ['malformed ID list', { allowed_service_ids: ['us-work', null] }], - ['wrong account subject', { sub: 'another-user' }], - ['expired JWT', { exp: 1 }], -] as const)('fails closed for %s without exposing token payloads or reading the inventory', async (_name, claims) => { - persistAuthSession( - createNyxIDServiceSession({ ...claims, note: 'TEST_ONLY_SECRET' }), - ); - await expect(listChannelServiceAccess()).rejects.toThrow( - 'Could not read the current NyxID service authorization.', - ); - expect(fetchMock).not.toHaveBeenCalled(); -}); - -it('does not treat locally decoded claims as successful server authorization', async () => { +it('propagates rejected NyxID authentication without returning inventory', async () => { persistAuthSession(createNyxIDServiceSession({ allow_all_services: true })); fetchMock.mockResolvedValue(response({ services: [personal] }, 401)); - await expect(listChannelServiceAccess()).rejects.toMatchObject({ + await expect(listChannelServices()).rejects.toMatchObject({ status: 401, }); }); -it('refreshes an expired session before filtering and pins the inventory request to the refreshed bearer', async () => { +it('refreshes an expired session before reading account services and pins the inventory request to the refreshed bearer', async () => { const old = createNyxIDServiceSession({ allowed_service_ids: ['us-old'] }); const fresh = createNyxIDServiceSession({ allowed_service_ids: ['us-work'] }); persistAuthSession({ @@ -200,10 +187,10 @@ it('refreshes an expired session before filtering and pins the inventory request response({ services: [personal, { ...personal, id: 'us-old' }] }), ); expect( - (await listChannelServiceAccess()) + (await listChannelServices()) .filter((service) => service.active && service.allowed) .map(({ id }) => id), - ).toEqual(['us-work']); + ).toEqual(['us-work', 'us-old']); expect(tokenFetch).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( inventoryPath, diff --git a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts index 7713e7160..b5ea3cee0 100644 --- a/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts +++ b/apps/aevatar-console-web/src/shared/api/channelServicesApi.ts @@ -1,7 +1,6 @@ import { ensureActiveAuthSession } from '@/shared/auth/client'; import { getNyxIDRuntimeConfig } from '@/shared/auth/config'; import { authFetch } from '@/shared/auth/fetch'; -import { readAccessTokenServiceGrants } from '@/shared/auth/serviceGrants'; import { ChannelApiError } from './channelsApi'; import { expectArray, @@ -34,8 +33,8 @@ function decodeService(value: unknown): ChannelServiceChoice { if (!id.trim() || !slug.trim()) throw new Error('Missing service identity.'); const personal = source.type === 'personal'; const organization = source.type === 'org'; - // This is account-level availability only. Current bearer access is checked - // separately against the current access token before returning choices. + // Channel selection uses account availability, independently of the services + // selected when signing in. Organization membership still controls access. return { id, slug, @@ -90,25 +89,12 @@ export async function listChannelServiceIdentities( return services.map(({ id, slug, label }) => ({ id, slug, label })); } -export async function listChannelServiceAccess( +// NyxID owns the account inventory. Login consent limits the console session's +// proxy access, not the explicit service selection for a channel's Agent Key. +export async function listChannelServices( signal?: AbortSignal, ): Promise { const session = await ensureActiveAuthSession(); if (!session) throw new ChannelApiError(401); - const grants = readAccessTokenServiceGrants( - session.tokens.accessToken, - session.user.sub, - ); - // Pin inventory and selectable choices to the same authenticated bearer. - const services = await readChannelServiceInventory( - session.tokens.accessToken, - signal, - ); - const authorizedIds = new Set(grants.allowedServiceIds); - return services.map((service) => ({ - ...service, - allowed: - service.allowed && - (grants.allowAllServices || authorizedIds.has(service.id)), - })); + return readChannelServiceInventory(session.tokens.accessToken, signal); } diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts new file mode 100644 index 000000000..1a26aa62a --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts @@ -0,0 +1,90 @@ +import { authFetch } from '@/shared/auth/fetch'; +import { getChannelSkillServices } from './channelSkillServicesApi'; + +jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); +const fetchMock = jest.mocked(authFetch); +const response = (value: unknown, status = 200) => + ({ ok: status === 200, status, json: async () => value }) as Response; + +afterEach(() => fetchMock.mockReset()); + +it('reads the backend contract with an encoded skill name and preserves exact instance identity', async () => { + fetchMock.mockResolvedValue( + response({ + skillName: 'team+docs', + suggestions: [ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'catalog', + instances: [ + { + id: 'us-team', + slug: 'api-github', + label: 'Team GitHub', + active: true, + allowed: false, + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + ], + }), + ); + const signal = new AbortController().signal; + expect(await getChannelSkillServices('team+docs', signal)).toEqual([ + { + slug: 'api-github', + label: 'GitHub', + evidence: 'catalog', + instances: [ + { + id: 'us-team', + slug: 'api-github', + label: 'Team GitHub', + active: true, + allowed: false, + source: 'organization', + organizationName: 'Acme', + }, + ], + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + '/api/skills/service-recommendations?skillName=team%2Bdocs', + { + signal, + cache: 'no-store', + }, + ); +}); + +it('rejects mismatched and unsupported results and never surfaces upstream error contents', async () => { + fetchMock.mockResolvedValueOnce( + response({ skillName: 'another-skill', suggestions: [] }), + ); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Mismatched skill recommendations.', + ); + fetchMock.mockResolvedValueOnce( + response({ + skillName: 'support', + suggestions: [ + { + slug: 'github', + label: 'GitHub', + evidence: 'new-unknown-kind', + instances: [], + }, + ], + }), + ); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Invalid service suggestion.', + ); + fetchMock.mockResolvedValueOnce(response({ error: 'TEST_ONLY_SECRET' }, 502)); + await expect(getChannelSkillServices('support')).rejects.toThrow( + 'Channel request failed (502).', + ); +}); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts new file mode 100644 index 000000000..afe7c9ef3 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts @@ -0,0 +1,92 @@ +import { authFetch } from '@/shared/auth/fetch'; +import type { ChannelServiceChoice } from './channelServicesApi'; +import { ChannelApiError } from './channelsApi'; +import { + expectArray, + expectBoolean, + expectRecord, + readString, +} from './http/decoders'; + +export interface SkillServiceRecommendation { + readonly slug: string; + readonly label: string; + readonly evidence: 'linked' | 'catalog' | 'mention'; + readonly instances: readonly ChannelServiceChoice[]; +} + +export async function getChannelSkillServices( + skillName: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ skillName }); + const response = await authFetch( + `/api/skills/service-recommendations?${params}`, + { + signal, + cache: 'no-store', + }, + ); + if (!response.ok) throw new ChannelApiError(response.status); + const body = expectRecord( + await response.json(), + 'Skill service recommendations', + ); + if (body.skillName !== skillName) + throw new Error('Mismatched skill recommendations.'); + const suggestions = expectArray( + body.suggestions, + 'Service suggestions', + (value) => { + const item = expectRecord(value, 'Service suggestion'); + const slug = readString(item, 'slug', 'Service slug'); + const label = readString(item, 'label', 'Service label'); + const evidence = item.evidence; + if ( + !slug.trim() || + !label.trim() || + (evidence !== 'linked' && + evidence !== 'catalog' && + evidence !== 'mention') + ) + throw new Error('Invalid service suggestion.'); + const instances = expectArray( + item.instances, + 'Service instances', + (entry): ChannelServiceChoice => { + const instance = expectRecord(entry, 'Service instance'); + const id = readString(instance, 'id', 'UserService ID'); + const source = instance.source; + if ( + !id.trim() || + instance.slug !== slug || + (source !== 'personal' && + source !== 'organization' && + source !== 'unknown') + ) + throw new Error('Invalid service instance.'); + return { + id, + slug, + source, + label: readString(instance, 'label', 'Instance label'), + active: expectBoolean(instance.active, 'Service activity'), + allowed: expectBoolean(instance.allowed, 'Account access'), + organizationName: + readString(instance, 'organizationName', 'Organization name') || + null, + }; + }, + ); + if ( + new Set(instances.map((instance) => instance.id)).size !== + instances.length + ) + throw new Error('Ambiguous service instances.'); + return { slug, label, evidence, instances }; + }, + ); + if (new Set(suggestions.map((item) => item.slug)).size !== suggestions.length) + throw new Error('Ambiguous service suggestions.'); + return suggestions; +} diff --git a/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts b/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts deleted file mode 100644 index 28d7a9d49..000000000 --- a/apps/aevatar-console-web/src/shared/auth/serviceGrants.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - expectArray, - expectBoolean, - expectRecord, - expectString, -} from '@/shared/api/http/decoders'; - -export interface NyxIDServiceGrants { - readonly allowAllServices: boolean; - readonly allowedServiceIds: readonly string[]; -} - -// Display filtering only: this does not verify a JWT signature or authorize an -// operation. NyxID validates the same bearer on the inventory request, and the -// registration endpoint remains responsible for enforcing delegation limits. -export function readAccessTokenServiceGrants( - accessToken: string, - expectedSubject: string, -): NyxIDServiceGrants { - try { - const parts = accessToken.split('.'); - if (parts.length !== 3 || parts.some((part) => !part)) throw new Error(); - const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const bytes = Uint8Array.from(atob(payload), (character) => - character.charCodeAt(0), - ); - const claims = expectRecord( - JSON.parse(new TextDecoder().decode(bytes)), - 'Access claims', - ); - if ( - claims.token_type !== 'access' || - claims.relay === true || - !expectedSubject || - claims.sub !== expectedSubject || - typeof claims.exp !== 'number' || - !Number.isFinite(claims.exp) || - claims.exp * 1000 <= Date.now() - ) - throw new Error(); - const allowAllServices = expectBoolean( - claims.allow_all_services, - 'Service grant mode', - ); - const allowedServiceIds = expectArray( - claims.allowed_service_ids, - 'Service grants', - (value) => { - const id = expectString(value, 'User service ID'); - if (!id.trim()) throw new Error(); - return id; - }, - ); - return { allowAllServices, allowedServiceIds }; - } catch { - // JSON/parser errors must never expose any part of the bearer payload. - throw new Error('Could not read the current NyxID service authorization.'); - } -}