Files
multica/server/internal/handler/lark.go
J c1b236f9cf feat(lark): serve Feishu and Lark from one deployment, per installation
The Lark integration was locked to a single open-platform host chosen
deployment-wide (MULTICA_LARK_HTTP_BASE_URL / _CALLBACK_BASE_URL,
defaulting to open.feishu.cn), so one deployment could talk to only the
mainland Feishu cloud OR Lark international — never both. Teams on the
other tenant could not use the integration at all.

Make the host per-installation. The device-flow installer already
auto-detects the tenant (Lark emits tenant_brand="lark" mid-poll); we now
persist that as lark_installation.region, carry it on
InstallationCredentials.Region, and resolve the open-platform host per
call (REST + WS bootstrap) from the region. An explicit cfg.BaseURL
(env / httptest) still overrides every region, so existing tests and
staging/proxy setups keep working.

- migration 116: lark_installation.region TEXT NOT NULL DEFAULT 'feishu'
  CHECK (region IN ('feishu','lark')) — existing rows are all mainland.
- lark.Region enum + OpenPlatformBaseURL/RegionOrDefault helpers.
- registration: thread the detected region into finishSuccess so the
  install-time GetBotInfo hits the right cloud AND the row records it.
- every credential-build site (patcher, replier, WS provider, union_id
  backfill) copies region off the installation row.
- region is part of the WS supervisor fingerprint so a re-install that
  switches cloud restarts the connection.
- API: surface region on the installation listing DTO.

MUL-3083

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:26:41 +08:00

358 lines
14 KiB
Go

package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/integrations/lark"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// LarkInstallationResponse is the wire shape for an installation row.
// `app_secret_encrypted` is INTENTIONALLY absent — the encrypted blob
// is server-internal and there is no product reason to expose it (the
// only consumer that needs the plaintext is the WS hub, which calls
// InstallationService.DecryptAppSecret server-side). Likewise, the WS
// lease columns are omitted; they are runtime state, not API surface.
type LarkInstallationResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
AgentID string `json:"agent_id"`
AppID string `json:"app_id"`
TenantKey *string `json:"tenant_key,omitempty"`
BotOpenID string `json:"bot_open_id"`
InstallerUserID string `json:"installer_user_id"`
Status string `json:"status"`
// Region is the Lark cloud this installation lives on: "feishu"
// (mainland) or "lark" (international). The UI uses it to render a
// badge and to build the correct "Manage in Lark" dev-console host.
Region string `json:"region"`
InstalledAt string `json:"installed_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func larkInstallationToResponse(row db.LarkInstallation) LarkInstallationResponse {
resp := LarkInstallationResponse{
ID: uuidToString(row.ID),
WorkspaceID: uuidToString(row.WorkspaceID),
AgentID: uuidToString(row.AgentID),
AppID: row.AppID,
BotOpenID: row.BotOpenID,
InstallerUserID: uuidToString(row.InstallerUserID),
Status: row.Status,
Region: row.Region,
InstalledAt: row.InstalledAt.Time.UTC().Format(time.RFC3339),
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
UpdatedAt: row.UpdatedAt.Time.UTC().Format(time.RFC3339),
}
if row.TenantKey.Valid {
tk := row.TenantKey.String
resp.TenantKey = &tk
}
return resp
}
// ListLarkInstallations (GET /api/workspaces/{id}/lark/installations)
// is member-visible — the Integrations tab should not render blank
// for non-admins. Unlike the GitHub list, we do not strip any field
// here because no API surface column doubles as a management handle:
// revocation goes by the UUID id, which is meaningless without the
// admin route's authorization, so exposing it is harmless.
//
// Response fields:
// - configured: at-rest encryption key is set (`LarkInstallations
// != nil`). When false, no install flow can succeed at all; the
// UI hides the tab.
// - install_supported: the device-flow install path is wired
// end-to-end: a RegistrationService exists (deployment supplied
// MULTICA_LARK_SECRET_KEY) AND the APIClient.IsConfigured signal
// is true (the real Lark HTTP client is in place — the stub
// cannot complete the post-poll GetBotInfo call). When false,
// the agent-detail "Bind" button stays hidden and the Settings
// tab surfaces a "coming soon" notice; already-installed bots
// still appear and remain manageable.
func (h *Handler) ListLarkInstallations(w http.ResponseWriter, r *http.Request) {
if h.LarkInstallations == nil {
writeJSON(w, http.StatusOK, map[string]any{
"installations": []LarkInstallationResponse{},
"configured": false,
"install_supported": false,
})
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
rows, err := h.LarkInstallations.ListByWorkspace(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list lark installations")
return
}
out := make([]LarkInstallationResponse, 0, len(rows))
for _, row := range rows {
out = append(out, larkInstallationToResponse(row))
}
writeJSON(w, http.StatusOK, map[string]any{
"installations": out,
"configured": true,
"install_supported": h.LarkRegistration != nil && h.LarkAPIClient != nil && h.LarkAPIClient.IsConfigured(),
})
}
// RevokeLarkInstallation (DELETE /api/workspaces/{id}/lark/installations/{installationId})
// flips status to 'revoked' so the WS hub drops the connection on its
// next sweep. The row itself is preserved for audit; a re-install via
// the device-flow path flips status back to 'active' atomically.
func (h *Handler) RevokeLarkInstallation(w http.ResponseWriter, r *http.Request) {
if h.LarkInstallations == nil {
writeError(w, http.StatusServiceUnavailable, "lark integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
instUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation id")
if !ok {
return
}
// Workspace-scoped lookup ensures one workspace cannot revoke
// another's installation by guessing the UUID.
if _, err := h.LarkInstallations.GetInWorkspace(r.Context(), instUUID, wsUUID); err != nil {
if errors.Is(err, lark.ErrInstallationNotFound) {
writeError(w, http.StatusNotFound, "lark installation not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load installation")
return
}
if err := h.LarkInstallations.Revoke(r.Context(), instUUID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke installation")
return
}
h.publish(protocol.EventLarkInstallationRevoked, uuidToString(wsUUID), "user", userID, map[string]any{
"id": uuidToString(instUUID),
})
w.WriteHeader(http.StatusNoContent)
}
// RedeemLarkBindingTokenRequest carries the raw token the user
// clicked through from the Bot's "you need to bind" reply card.
type RedeemLarkBindingTokenRequest struct {
Token string `json:"token"`
}
// RedeemLarkBindingTokenResponse is the post-redemption shape. We
// echo the workspace/installation/open_id so the frontend can render
// "you are now bound to <workspace> via <agent>" without a second
// fetch.
type RedeemLarkBindingTokenResponse struct {
WorkspaceID string `json:"workspace_id"`
InstallationID string `json:"installation_id"`
LarkOpenID string `json:"lark_open_id"`
}
// RedeemLarkBindingToken (POST /api/lark/binding/redeem) is the only
// path that writes a lark_user_binding row from user-driven action.
// The redeemer's identity is taken from the session, not the token,
// so a stolen token cannot bind a Lark open_id to an attacker's
// Multica account. The token only proves "this open_id requested
// binding" — combining it with the logged-in user is what creates
// the (open_id ↔ user) mapping.
//
// Consume + bind happen inside a single DB transaction (see
// lark.BindingTokenService.RedeemAndBind). The three failure modes
// each map to a distinct status code so the frontend can render the
// appropriate copy without a separate probe:
// - 410 Gone: token unknown / consumed / expired
// - 409 Conflict: open_id is already bound to a different user
// - 403 Forbidden: redeemer is not a workspace member
func (h *Handler) RedeemLarkBindingToken(w http.ResponseWriter, r *http.Request) {
if h.LarkBindingTokens == nil {
writeError(w, http.StatusServiceUnavailable, "lark integration not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req RedeemLarkBindingTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "token is required")
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
redeemed, err := h.LarkBindingTokens.RedeemAndBind(r.Context(), req.Token, userUUID)
if err != nil {
switch {
case errors.Is(err, lark.ErrBindingTokenInvalid):
writeError(w, http.StatusGone, "binding token invalid or expired")
case errors.Is(err, lark.ErrBindingAlreadyAssigned):
writeError(w, http.StatusConflict, "this Lark account is already bound to a different Multica user")
case errors.Is(err, lark.ErrBindingNotWorkspaceMember):
writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)")
default:
writeError(w, http.StatusInternalServerError, "failed to redeem token")
}
return
}
writeJSON(w, http.StatusOK, RedeemLarkBindingTokenResponse{
WorkspaceID: uuidToString(redeemed.WorkspaceID),
InstallationID: uuidToString(redeemed.InstallationID),
LarkOpenID: string(redeemed.LarkOpenID),
})
}
// BeginLarkInstallResponse is the payload the QR-code dialog consumes.
// The frontend renders `qr_code_url` as a QR image (and as a tap-to-
// open link fallback) and starts polling
// /lark/install/{session_id}/status at the supplied cadence.
type BeginLarkInstallResponse struct {
SessionID string `json:"session_id"`
QRCodeURL string `json:"qr_code_url"`
ExpiresInSeconds int `json:"expires_in_seconds"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
// BeginLarkInstall (POST /api/workspaces/{id}/lark/install/begin)
// opens a new device-flow registration session against Lark. Admin-only
// at the router. The agent_id query param picks which Multica Agent
// the new Bot will be bound to; the agent must belong to this
// workspace (RegistrationService re-checks that defense-in-depth).
//
// Returns 503 when the integration is not wired (no at-rest key, no
// HTTP client, no RegistrationService); the UI hides the bind button
// in that case so this should not be reached through the normal flow.
func (h *Handler) BeginLarkInstall(w http.ResponseWriter, r *http.Request) {
if h.LarkRegistration == nil {
writeError(w, http.StatusServiceUnavailable, "lark install not configured")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
agentIDStr := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if agentIDStr == "" {
writeError(w, http.StatusBadRequest, "agent_id is required")
return
}
agentUUID, ok := parseUUIDOrBadRequest(w, agentIDStr, "agent_id")
if !ok {
return
}
// Ownership pre-check at the HTTP boundary so a malformed
// agent_id surfaces 404 here (not an opaque service error from
// inside the service's own re-check).
if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
ID: agentUUID,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "agent not found in this workspace")
return
}
initiatorUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
res, err := h.LarkRegistration.BeginInstall(r.Context(), lark.BeginInstallParams{
WorkspaceID: wsUUID,
AgentID: agentUUID,
InitiatorID: initiatorUUID,
})
if err != nil {
writeError(w, http.StatusBadGateway, "failed to start install: "+err.Error())
return
}
writeJSON(w, http.StatusOK, BeginLarkInstallResponse{
SessionID: res.SessionID,
QRCodeURL: res.QRCodeURL,
ExpiresInSeconds: res.ExpiresInSeconds,
PollIntervalSeconds: res.PollIntervalSeconds,
})
}
// LarkInstallStatusResponse is the polling payload. `status` is one
// of "pending" | "success" | "error"; on success `installation_id`
// is populated, on error `error_reason` is a stable code (see
// lark.RegistrationReason*).
type LarkInstallStatusResponse struct {
Status string `json:"status"`
InstallationID string `json:"installation_id,omitempty"`
ErrorReason string `json:"error_reason,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// GetLarkInstallStatus (GET /api/workspaces/{id}/lark/install/{sessionId}/status)
// returns the current state of an in-flight install session. Admin-
// only at the router. Unknown / cross-workspace / GC'd sessions return
// 404 — the frontend treats it as "session lost, please restart".
//
// On success this handler does NOT clean up the session — the
// frontend may poll once more after the dialog closes to confirm
// before the in-process GC sweep retires the entry; reading is
// idempotent.
func (h *Handler) GetLarkInstallStatus(w http.ResponseWriter, r *http.Request) {
if h.LarkRegistration == nil {
writeError(w, http.StatusServiceUnavailable, "lark install not configured")
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id")
if !ok {
return
}
sessionID := strings.TrimSpace(chi.URLParam(r, "sessionId"))
if sessionID == "" {
writeError(w, http.StatusBadRequest, "session id is required")
return
}
state, err := h.LarkRegistration.GetSession(wsUUID, sessionID)
if err != nil {
if errors.Is(err, lark.ErrRegistrationSessionNotFound) {
writeError(w, http.StatusNotFound, "install session not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load install session")
return
}
resp := LarkInstallStatusResponse{
Status: string(state.Status),
ErrorReason: state.ErrorReason,
ErrorMessage: state.ErrorMessage,
}
if state.InstallationID.Valid {
resp.InstallationID = uuidToString(state.InstallationID)
// The lark_installation:created event is published by the
// RegistrationService at the row-commit point (see
// registration_service.go finishSuccess), not here — that keeps
// the connection-badge refresh independent of whether any browser
// polls this status endpoint to success.
}
writeJSON(w, http.StatusOK, resp)
}