Skip to content

[Channel RFC] Adopt Lark -> NyxID -> Aevatar relay webhook; make Nyx the sole credential authority #296

@eanzhao

Description

@eanzhao

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 -> 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:

  1. Lark webhook URL points to NyxID
  2. NyxID verifies/decrypts/parses/stores inbound
  3. NyxID pushes CallbackPayload to Aevatar
  4. 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
  • Record explicitly that for Lark, this issue supersedes [Channel RFC] Lark outbound + streaming wire-through on LarkChannelAdapter (post-#261) #294 Deliverable A and sets the outbound target contract to channel-relay/reply

B. Provisioning / ownership model

  • Registration in Aevatar stores only non-secret Nyx handles:
    • nyx_channel_bot_id
    • nyx_agent_api_key_id
    • route_id if Aevatar creates and stores it during provisioning
    • status flags / timestamps
  • If Aevatar accepts app_id / app_secret during provisioning, it forwards them to Nyx and does not persist them locally
  • Document the current manual Lark console step: the webhook URL must point to Nyx until Nyx supports programmatic registration

C. Inbound runtime refactor

  • Retire production use of POST /api/channels/lark/callback/{registrationId}
  • Remove Lark URL verification, signature verification, AES-CBC decryption, and direct platform event parsing from the supported Lark runtime path
  • Make /api/webhooks/nyxid-relay the inbound edge
  • Change the Nyx relay endpoint contract to:
    • verify Nyx-issued relay JWT
    • map CallbackPayload to typed ChatActivity / channel ingress message
    • commit durable ingress
    • return 202
    • never rely on sync reply bodies

D. Outbound runtime refactor

  • Introduce INyxChannelRelayClient or equivalent outbound port implementation
  • Use Nyx message_id from the callback payload for reply correlation
  • Use the callback-scoped relay JWT in-memory only for channel-relay/reply
  • Replace the current Lark reply contract assumption (proxy + persisted Nyx session/refresh token) with channel-relay/reply + callback relay JWT

E. Schema / state / readmodel cleanup

From channel_runtime_messages.proto, actor state, projector, and readmodel:

  • remove encrypt_key
  • remove verification_token
  • remove credential_ref for Lark channel registrations
  • remove nyx_user_token
  • remove nyx_refresh_token
  • remove nyx_provider_slug from the Lark runtime path if it only exists to support the older Nyx proxy contract
  • add nyx_channel_bot_id
  • add nyx_agent_api_key_id
  • add any other non-secret route/status identifiers actually required by runtime behavior

F. Relay auth hardening

  • Implement real Nyx JWT validation for /api/webhooks/nyxid-relay using OIDC discovery + JWKS
  • Cover issuer/audience validation, signature validation, TTL/expiry checks, JWKS refresh/caching, kid rotation, and bounded clock skew
  • Reject missing / invalid / mismatched callback auth
  • 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

Relationship to #295

For the target relay design defined here:

  • reply auth comes from the callback-scoped Nyx relay JWT
  • that JWT is used in-memory only
  • no per-registration or Aevatar-wide persisted Nyx session/refresh token is part of the target end state

So #295 is not a dependency for the target end state of this issue.

References

  • Parent RFC: [RFC] Aevatar Chat — Multi-Channel Adapter Architecture #254
  • Aevatar direct callback path:
    • agents/Aevatar.GAgents.ChannelRuntime/ChannelCallbackEndpoints.cs
    • agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs
  • Aevatar current Nyx relay endpoint:
    • agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs
  • Nyx webhook + relay path:
    • ~/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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions