Summary
/daily (and any other agent_builder flow that mints a new NyxID API key) consistently fails preflight with github_proxy_access_denied even when the user's GitHub OAuth connection at NyxID is healthy. The BuildGitHubAuthorizationResponseAsync check passes (NyxID reports the GitHub provider connected, OAuth token valid), but the very next request — GET /api/v1/proxy/s/api-github/rate_limit using the freshly minted api-key — returns 403 from NyxID's ApiKeyScopeForbidden enforcement.
The preflight added under #411 catches this 403 and surfaces it to the user with a hint to "verify the GitHub OAuth provider is connected at NyxID". That hint is misleading — reconnecting GitHub does not fix the issue, because the underlying cause is in our api-key payload, not in NyxID's GitHub binding.
Root cause
AgentBuilderTool.cs:BuildCreateApiKeyPayload populates allowed_service_ids with the wrong ID type.
- We currently call
GET /api/v1/proxy/services (NyxID catalog list) and read each item's id.
id on that endpoint is DownstreamService.id — a global catalog UUID shared across all users.
- NyxID's proxy enforcement (
backend/src/handlers/proxy.rs:1030-1055) compares the api-key's allowed_service_ids against UserService.id — a per-user instance UUID created when a user first connects to that catalog service.
- These two UUIDs are different. The api-key carries the catalog UUID, NyxID enforces against the user-instance UUID, the
contains() check fails, request is denied as ApiKeyScopeForbidden → 403.
NyxID's data model (per backend/src/models/user_service.rs):
pub struct UserService {
pub id: String, // ← per-user instance UUID — what enforcement checks
pub slug: String, // e.g. "api-github"
pub catalog_service_id: Option<String>, // ← DownstreamService.id — what we currently fill
...
}
Enforcement excerpt (NyxID proxy.rs:1030):
if let Some(ref us_id) = pre.user_service_id // ← UserService.id
&& !auth_user.allow_all_services
&& !auth_user.allowed_service_ids.contains(us_id)
{
return Err(AppError::ApiKeyScopeForbidden(...)); // → 403
}
Why this looked like an OAuth issue
Issue #411 added the GitHub preflight precisely to "fail fast with an actionable error rather than persisting an agent that will never produce a usable report." The hypothesis at the time was that the api-key was created with allowed_service_ids=api-github but the GitHub credential binding was missing, so every scheduled run would 403.
That diagnosis was wrong about why the 403 happens. The 403 is from ApiKeyScopeForbidden, not from upstream GitHub denying the call. The fix landed in #411 wraps the symptom (403 from /rate_limit) in a human-readable hint pointing to NyxID OAuth re-connection — which has no effect because the OAuth side is already correct. The BuildGitHubAuthorizationResponseAsync check upstream of the preflight is what actually catches genuine "OAuth not connected" cases, and it works.
Affected paths
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:871-919 (ResolveProxyServiceIdsAsync) — reads catalog id from /proxy/services response
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:664-678 (BuildCreateApiKeyPayload) — passes those ids straight into allowed_service_ids
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:1454-1524 (PreflightGitHubProxyAsync) — issues github_proxy_access_denied with a misleading hint
src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs:DiscoverProxyServicesAsync — currently hits /api/v1/proxy/services (catalog endpoint, wrong layer)
Affects every code path that creates an agent api-key via NyxID:
/daily (create_daily_report) — file AgentBuilderTool.cs:166-249
/social-media and social-media alias (create_social_media) — file AgentBuilderTool.cs:~358
- Any future
agent_builder template that requires NyxID proxy services
Proposed fix (P0)
Use NyxID's user-services endpoint to obtain UserService.id instead of DownstreamService.id.
- Add
ListUserServicesAsync(token, ct) to Aevatar.AI.ToolProviders.NyxId.NyxIdApiClient calling GET /api/v1/user-services. Response shape (UserServiceResponse, NyxID user_services_handler.rs:90-130):
[{"id":"<UserService.id>","slug":"api-github","catalog_service_id":"<catalog uuid>", "is_active": true, ...}]
- Rewrite
ResolveProxyServiceIdsAsync to:
- call the user-services endpoint
- filter rows by
slug matching each required slug
- require
is_active == true
- extract each row's
id (which is the per-user UserService.id)
- return that list as the api-key payload's
allowed_service_ids
- Distinguish two failure modes that today are conflated:
- slug not connected for this user (no
UserService row matches) → return a structured error: "service_not_connected" and a hint to connect the provider at NyxID. This is the case the preflight thought it was catching.
- slug exists but is
is_active == false → error: "service_inactive".
Once allowed_service_ids carries the correct UserService.id, the api-key passes enforcement and the GitHub /rate_limit probe will return 200 (or whatever upstream GitHub actually says).
Cleanup follow-up (P2, after P0 lands)
- Drop
PreflightGitHubProxyAsync (or repurpose it) — once the api-key is minted with the right scope, the only legitimate sources of 403 from /proxy/s/api-github are GitHub-side issues (revoked token, scope downgrade) that BuildGitHubAuthorizationResponseAsync should catch one step earlier. Keeping a probe-and-revoke dance for hypothetical NyxID bindings is no longer load-bearing.
- Rephrase the user-facing error message. The current text — "Verify the GitHub OAuth provider is connected at NyxID and that the key picks up the binding" — is now actively misleading.
Optional follow-up at NyxID (P1)
Could be raised as a separate issue at ChronoAIProject/NyxID: let api-key allowed_service_ids also accept catalog DownstreamService.id or service slug as aliases, so future SDK consumers don't repeat this footgun. Not required if we land the backend-side fix.
Acceptance
ResolveProxyServiceIdsAsync calls /api/v1/user-services, returns UserService.id values
/daily happy path with a healthy GitHub connection at NyxID succeeds end-to-end (api-key minted, GitHub rate_limit probe 200, daily report agent persisted, first run scheduled)
/daily when the user has not connected GitHub at NyxID returns the existing oauth_required/credentials_required error from BuildGitHubAuthorizationResponseAsync — never github_proxy_access_denied from the preflight
- Same for
/social-media
- Unit test: given a fake
/user-services response with two slugs, ResolveProxyServiceIdsAsync returns the per-user instance UUIDs, not the catalog_service_id values
- Unit test: missing slug surfaces
service_not_connected, not the generic "could not parse" error
Related
Summary
/daily(and any otheragent_builderflow that mints a new NyxID API key) consistently fails preflight withgithub_proxy_access_deniedeven when the user's GitHub OAuth connection at NyxID is healthy. TheBuildGitHubAuthorizationResponseAsynccheck passes (NyxID reports the GitHub provider connected, OAuth token valid), but the very next request —GET /api/v1/proxy/s/api-github/rate_limitusing the freshly minted api-key — returns 403 from NyxID'sApiKeyScopeForbiddenenforcement.The preflight added under #411 catches this 403 and surfaces it to the user with a hint to "verify the GitHub OAuth provider is connected at NyxID". That hint is misleading — reconnecting GitHub does not fix the issue, because the underlying cause is in our api-key payload, not in NyxID's GitHub binding.
Root cause
AgentBuilderTool.cs:BuildCreateApiKeyPayloadpopulatesallowed_service_idswith the wrong ID type.GET /api/v1/proxy/services(NyxID catalog list) and read each item'sid.idon that endpoint isDownstreamService.id— a global catalog UUID shared across all users.backend/src/handlers/proxy.rs:1030-1055) compares the api-key'sallowed_service_idsagainstUserService.id— a per-user instance UUID created when a user first connects to that catalog service.contains()check fails, request is denied asApiKeyScopeForbidden→ 403.NyxID's data model (per
backend/src/models/user_service.rs):Enforcement excerpt (NyxID
proxy.rs:1030):Why this looked like an OAuth issue
Issue #411 added the GitHub preflight precisely to "fail fast with an actionable error rather than persisting an agent that will never produce a usable report." The hypothesis at the time was that the api-key was created with
allowed_service_ids=api-githubbut the GitHub credential binding was missing, so every scheduled run would 403.That diagnosis was wrong about why the 403 happens. The 403 is from
ApiKeyScopeForbidden, not from upstream GitHub denying the call. The fix landed in #411 wraps the symptom (403 from /rate_limit) in a human-readable hint pointing to NyxID OAuth re-connection — which has no effect because the OAuth side is already correct. TheBuildGitHubAuthorizationResponseAsynccheck upstream of the preflight is what actually catches genuine "OAuth not connected" cases, and it works.Affected paths
agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:871-919(ResolveProxyServiceIdsAsync) — reads catalogidfrom/proxy/servicesresponseagents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:664-678(BuildCreateApiKeyPayload) — passes those ids straight intoallowed_service_idsagents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:1454-1524(PreflightGitHubProxyAsync) — issuesgithub_proxy_access_deniedwith a misleading hintsrc/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs:DiscoverProxyServicesAsync— currently hits/api/v1/proxy/services(catalog endpoint, wrong layer)Affects every code path that creates an agent api-key via NyxID:
/daily(create_daily_report) — fileAgentBuilderTool.cs:166-249/social-mediaandsocial-mediaalias (create_social_media) — fileAgentBuilderTool.cs:~358agent_buildertemplate that requires NyxID proxy servicesProposed fix (P0)
Use NyxID's user-services endpoint to obtain
UserService.idinstead ofDownstreamService.id.ListUserServicesAsync(token, ct)toAevatar.AI.ToolProviders.NyxId.NyxIdApiClientcallingGET /api/v1/user-services. Response shape (UserServiceResponse, NyxIDuser_services_handler.rs:90-130):[{"id":"<UserService.id>","slug":"api-github","catalog_service_id":"<catalog uuid>", "is_active": true, ...}]ResolveProxyServiceIdsAsyncto:slugmatching each required slugis_active == trueid(which is the per-userUserService.id)allowed_service_idsUserServicerow matches) → return a structurederror: "service_not_connected"and a hint to connect the provider at NyxID. This is the case the preflight thought it was catching.is_active == false→error: "service_inactive".Once
allowed_service_idscarries the correctUserService.id, the api-key passes enforcement and the GitHub/rate_limitprobe will return 200 (or whatever upstream GitHub actually says).Cleanup follow-up (P2, after P0 lands)
PreflightGitHubProxyAsync(or repurpose it) — once the api-key is minted with the right scope, the only legitimate sources of403from/proxy/s/api-githubare GitHub-side issues (revoked token, scope downgrade) thatBuildGitHubAuthorizationResponseAsyncshould catch one step earlier. Keeping a probe-and-revoke dance for hypothetical NyxID bindings is no longer load-bearing.Optional follow-up at NyxID (P1)
Could be raised as a separate issue at
ChronoAIProject/NyxID: let api-keyallowed_service_idsalso accept catalogDownstreamService.idor serviceslugas aliases, so future SDK consumers don't repeat this footgun. Not required if we land the backend-side fix.Acceptance
ResolveProxyServiceIdsAsynccalls/api/v1/user-services, returnsUserService.idvalues/dailyhappy path with a healthy GitHub connection at NyxID succeeds end-to-end (api-key minted, GitHub rate_limit probe 200, daily report agent persisted, first run scheduled)/dailywhen the user has not connected GitHub at NyxID returns the existingoauth_required/credentials_requirederror fromBuildGitHubAuthorizationResponseAsync— nevergithub_proxy_access_deniedfrom the preflight/social-media/user-servicesresponse with two slugs,ResolveProxyServiceIdsAsyncreturns the per-user instance UUIDs, not thecatalog_service_idvaluesservice_not_connected, not the generic "could not parse" errorRelated
/agents502 (different root cause, same incident)