You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue is scoped to Lark only and adopts exactly one target topology:
Lark Service -> NyxID -> Aevatar
The older direct path
Lark Service -> Aevatar
is a retired implementation, not a fallback contract.
This is the only webhook architecture compatible with the hard requirement for this issue:
NyxID is the sole authority for Lark channel-bot credentials and platform API calls
Aevatar persists zero Lark credentials
Aevatar does not perform Lark signature verification, decryption, or direct platform API calls
Aevatar does not keep a rollback-window direct Lark ingress contract
Long connection is out of scope for this issue and deferred to future voice / gateway work.
Telegram migration is also out of scope here and should be handled in a separate follow-up.
Why This Path
Direct Lark -> Aevatar makes Aevatar own verification_token, encrypt_key, callback signature verification, AES-CBC decryption, and platform-specific webhook parsing.
That conflicts with the target of "Aevatar stores no Lark credentials".
NyxID already owns the channel webhook receiver, route resolution, callback forwarding, and async reply surface.
Therefore the production path for this issue is:
Lark webhook URL points to NyxID
NyxID verifies/decrypts/parses/stores inbound
NyxID pushes CallbackPayload to Aevatar
Aevatar processes the turn and replies through POST /api/v1/channel-relay/reply
The outbound delta in this issue is contract migration:
from Nyx proxy + persisted Nyx session/refresh token assumptions
to channel-relay/reply + callback-scoped Nyx relay JWT
Hard Constraints
Aevatar must not persist app_id, app_secret, verification_token, encrypt_key, platform access tokens, Nyx user session tokens, Nyx refresh tokens, Nyx API keys, or Nyx API-key-hash-equivalent signing material in actor state, readmodels, or local secret stores for this path.
Aevatar must not keep a second Lark webhook ingress or rollback-window direct callback contract for Lark.
Aevatar may keep only non-secret identifiers returned during provisioning or required for routing/query, such as nyx_channel_bot_id, nyx_agent_api_key_id, route_id, status flags, and timestamps.
Callback-scoped Nyx relay JWTs supplied by Nyx may be used in-memory during the current request / turn only. They must not be persisted to actor state, readmodels, or local secret stores.
Current Nyx limitation: Lark callback forwarding only covers im.message.receive_v1; card.action.trigger is not forwarded today.
Current Nyx limitation: Lark webhook registration is still manual in the Lark Developer Console; Nyx register_webhook() is a no-op.
Target Contract
Inbound
Lark callback URL is Nyx webhook URL: POST /api/v1/webhooks/channel/lark/{bot_id}
Aevatar receives only Nyx relay callbacks at POST /api/webhooks/nyxid-relay
Nyx pushes CallbackPayload plus Nyx auth headers
Aevatar acknowledges with HTTP 202 only after durable ingress commit; synchronous reply bodies are not part of the target contract
Callback Auth
Nyx callback auth is centered on X-NyxID-User-Token, which in current Nyx is a relay-scoped JWT minted per callback and scoped to the assigned agent key.
Aevatar must validate that JWT using Nyx OIDC discovery + JWKS, including issuer/audience checks, signature validation, TTL handling, JWKS cache/refresh, kid rotation, and bounded clock-skew handling.
Current Nyx relay JWTs carry relay_api_key_id; current Nyx auth middleware maps that into AuthUser.api_key_id, so the same callback-scoped relay JWT is the intended auth surface for POST /api/v1/channel-relay/reply when the conversation's agent_api_key_id matches.
Aevatar binds the callback to its stored non-secret registration identifiers and payload message/conversation identity.
X-NyxID-Signature remains defense-in-depth only under the current zero-secret constraint.
Outbound
Turn replies go through POST /api/v1/channel-relay/reply
The reply key is Nyx message_id, not platform message_id
Auth uses the callback-scoped Nyx relay JWT from the current Nyx callback, in-memory only
No direct Lark send path remains in Aevatar for this issue
Proactive Outbound
This issue does not establish a new proactive channel-send credential model.
It covers inbound-triggered turns and their async replies.
Any future proactive send contract must be handled in a separate NyxID/Aevatar issue.
Scope
In
adopt Lark -> NyxID -> Aevatar as the only supported production ingress path for Lark
remove Aevatar-side Lark credential ownership and platform crypto from the Lark production runtime path
move turn-reply path to Nyx channel-relay/reply
prune all credential-bearing fields from Lark channel registration state/readmodel
redesign card-action-dependent flows to text / open_url / deep-link interactions where needed
explicitly migrate shipped social_media template and approval-style card flows away from card.action.trigger
Out
direct Lark -> Aevatar as a supported runtime or rollback contract
Aevatar-side vault semantics for Lark credentials
proactive outbound credential model
NyxID-side feature work such as card.action.trigger forwarding or programmatic webhook registration
voice / long-connection design
Telegram migration
Deliverables
A. Lock the architecture to Nyx relay webhook
Record in an ADR under docs/decisions/ that production ingress for Lark is Lark -> NyxID -> Aevatar
Record that the Lark callback URL points to Nyx, not Aevatar
Record that Aevatar receives only Nyx relay callbacks at /api/webhooks/nyxid-relay
Record that no rollback-window direct Lark callback contract remains
Do not persist the raw relay JWT outside the current request / turn
G. Card behavior under current Nyx constraints
Because Nyx Lark ingress currently forwards im.message.receive_v1 only:
display cards are allowed if they fit the current Nyx reply contract
open_url / deep-link cards are the supported interactive pattern in this issue
the shipped social_media template and approval-style flows that currently depend on card.action.trigger must be redesigned before cutover
this issue must not keep a second direct Aevatar Lark webhook path just to preserve card callbacks
H. Cutover
create / verify the Nyx callback route to Aevatar
build /api/webhooks/nyxid-relay JWT validation and channel-relay/reply reply path first
flip the Lark Developer Console callback URL to Nyx
remove the direct Aevatar Lark callback path from the supported runtime contract
return 410 or delete POST /api/channels/lark/callback/{registrationId}
Acceptance
ADR under docs/decisions/ records Lark -> NyxID -> Aevatar as the only supported production webhook topology for this issue
Production Lark callback URL points to Nyx, not Aevatar
Aevatar persists zero Lark credentials in actor state and readmodels
Aevatar persists zero long-lived Nyx session/refresh/API-key credentials for this relay path
/api/webhooks/nyxid-relay validates Nyx relay JWT via JWKS and returns 202 after durable ingress commit
Replies use Nyx channel-relay/reply with Nyx message_id
Callback-scoped relay JWT is used in-memory only and is not persisted
encrypt_key, verification_token, credential_ref, nyx_user_token, and nyx_refresh_token are removed from the Lark registration proto/state/readmodel path
ChannelBotRegistrationDocument contains only non-secret identifiers / status fields for Lark
social_media / approval flows no longer depend on card.action.trigger; they work through text or open_url / deep-link interactions over the Nyx relay path
POST /api/channels/lark/callback/{registrationId} is deleted or returns 410 Gone
No rollback-window direct Lark callback contract remains
Parent RFC: #254
Relates to: #261 (closed by #288), #288, #289, #294, #295
Supersedes for Lark: #294 Deliverable A
Decision
This issue is scoped to Lark only and adopts exactly one target topology:
Lark Service -> NyxID -> AevatarThe older direct path
Lark Service -> Aevataris a retired implementation, not a fallback contract.
This is the only webhook architecture compatible with the hard requirement for this issue:
Long connection is out of scope for this issue and deferred to future voice / gateway work.
Telegram migration is also out of scope here and should be handled in a separate follow-up.
Why This Path
Direct
Lark -> Aevatarmakes Aevatar ownverification_token,encrypt_key, callback signature verification, AES-CBC decryption, and platform-specific webhook parsing.That conflicts with the target of "Aevatar stores no Lark credentials".
NyxID already owns the channel webhook receiver, route resolution, callback forwarding, and async reply surface.
Therefore the production path for this issue is:
CallbackPayloadto AevatarPOST /api/v1/channel-relay/replyThe outbound delta in this issue is contract migration:
Nyx proxy + persisted Nyx session/refresh token assumptionschannel-relay/reply + callback-scoped Nyx relay JWTHard Constraints
app_id,app_secret,verification_token,encrypt_key, platform access tokens, Nyx user session tokens, Nyx refresh tokens, Nyx API keys, or Nyx API-key-hash-equivalent signing material in actor state, readmodels, or local secret stores for this path.nyx_channel_bot_id,nyx_agent_api_key_id,route_id, status flags, and timestamps.im.message.receive_v1;card.action.triggeris not forwarded today.register_webhook()is a no-op.Target Contract
Inbound
POST /api/v1/webhooks/channel/lark/{bot_id}POST /api/webhooks/nyxid-relayCallbackPayloadplus Nyx auth headers202only after durable ingress commit; synchronous reply bodies are not part of the target contractCallback Auth
X-NyxID-User-Token, which in current Nyx is a relay-scoped JWT minted per callback and scoped to the assigned agent key.kidrotation, and bounded clock-skew handling.relay_api_key_id; current Nyx auth middleware maps that intoAuthUser.api_key_id, so the same callback-scoped relay JWT is the intended auth surface forPOST /api/v1/channel-relay/replywhen the conversation'sagent_api_key_idmatches.X-NyxID-Signatureremains defense-in-depth only under the current zero-secret constraint.Outbound
POST /api/v1/channel-relay/replymessage_id, not platformmessage_idProactive Outbound
This issue does not establish a new proactive channel-send credential model.
It covers inbound-triggered turns and their async replies.
Any future proactive send contract must be handled in a separate NyxID/Aevatar issue.
Scope
In
Lark -> NyxID -> Aevataras the only supported production ingress path for Larkchannel-relay/replyopen_url/ deep-link interactions where neededsocial_mediatemplate and approval-style card flows away fromcard.action.triggerOut
Lark -> Aevataras a supported runtime or rollback contractcard.action.triggerforwarding or programmatic webhook registrationDeliverables
A. Lock the architecture to Nyx relay webhook
docs/decisions/that production ingress for Lark isLark -> NyxID -> Aevatar/api/webhooks/nyxid-relaychannel-relay/replyB. Provisioning / ownership model
nyx_channel_bot_idnyx_agent_api_key_idroute_idif Aevatar creates and stores it during provisioningapp_id/app_secretduring provisioning, it forwards them to Nyx and does not persist them locallyC. Inbound runtime refactor
POST /api/channels/lark/callback/{registrationId}/api/webhooks/nyxid-relaythe inbound edgeCallbackPayloadto typedChatActivity/ channel ingress message202D. Outbound runtime refactor
INyxChannelRelayClientor equivalent outbound port implementationmessage_idfrom the callback payload for reply correlationchannel-relay/replyproxy + persisted Nyx session/refresh token) withchannel-relay/reply + callback relay JWTE. Schema / state / readmodel cleanup
From
channel_runtime_messages.proto, actor state, projector, and readmodel:encrypt_keyverification_tokencredential_reffor Lark channel registrationsnyx_user_tokennyx_refresh_tokennyx_provider_slugfrom the Lark runtime path if it only exists to support the older Nyx proxy contractnyx_channel_bot_idnyx_agent_api_key_idF. Relay auth hardening
/api/webhooks/nyxid-relayusing OIDC discovery + JWKSkidrotation, and bounded clock skewG. Card behavior under current Nyx constraints
Because Nyx Lark ingress currently forwards
im.message.receive_v1only:open_url/ deep-link cards are the supported interactive pattern in this issuesocial_mediatemplate and approval-style flows that currently depend oncard.action.triggermust be redesigned before cutoverH. Cutover
/api/webhooks/nyxid-relayJWT validation andchannel-relay/replyreply path first410or deletePOST /api/channels/lark/callback/{registrationId}Acceptance
docs/decisions/recordsLark -> NyxID -> Aevataras the only supported production webhook topology for this issue/api/webhooks/nyxid-relayvalidates Nyx relay JWT via JWKS and returns202after durable ingress commitchannel-relay/replywith Nyxmessage_idencrypt_key,verification_token,credential_ref,nyx_user_token, andnyx_refresh_tokenare removed from the Lark registration proto/state/readmodel pathChannelBotRegistrationDocumentcontains only non-secret identifiers / status fields for Larksocial_media/ approval flows no longer depend oncard.action.trigger; they work through text oropen_url/ deep-link interactions over the Nyx relay pathPOST /api/channels/lark/callback/{registrationId}is deleted or returns410 GoneRelationship to #295
For the target relay design defined here:
So #295 is not a dependency for the target end state of this issue.
References
agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.csagents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.csagents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs~/Code/NyxID/backend/src/routes.rs~/Code/NyxID/backend/src/handlers/channel_webhooks.rs~/Code/NyxID/backend/src/services/channel_routing_service.rs~/Code/NyxID/backend/src/handlers/channel_relay.rs~/Code/NyxID/backend/src/services/channel_relay_service.rs~/Code/NyxID/backend/src/crypto/jwt.rs~/Code/NyxID/backend/src/mw/auth.rs~/Code/NyxID/backend/src/handlers/oidc_discovery.rs~/Code/NyxID/backend/src/services/channel_adapters/lark.rs