diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index f4953d5c9..d8bedfe32 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -21,12 +21,14 @@ import ( "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/integrations/lark" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" "github.com/multica-ai/multica/server/internal/storage" "github.com/multica-ai/multica/server/internal/util" + "github.com/multica-ai/multica/server/internal/util/secretbox" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -152,6 +154,31 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus h.WebhookRateLimiter = handler.NewRedisWebhookRateLimiter(rdb, handler.DefaultWebhookRateLimit()) h.WebhookIPRateLimiter = handler.NewRedisWebhookIPRateLimiter(rdb, handler.DefaultWebhookIPRateLimit()) } + + // Lark integration. Only wired when MULTICA_LARK_SECRET_KEY is set: + // the InstallationService refuses to fall back to plaintext storage + // for app_secret, and the BindingTokenService cannot mint usable + // tokens without it either. When the key is absent the Lark + // handlers return 503 with a clear message; the rest of the server + // continues to start so self-host deployments that have not opted + // in to Lark are unaffected. + if larkKey, err := secretbox.LoadKey("MULTICA_LARK_SECRET_KEY"); err == nil { + box, err := secretbox.New(larkKey) + if err != nil { + slog.Error("lark: secretbox.New failed; lark integration disabled", "error", err) + } else { + installSvc, err := lark.NewInstallationService(queries, box) + if err != nil { + slog.Error("lark: InstallationService init failed; lark integration disabled", "error", err) + } else { + h.LarkInstallations = installSvc + h.LarkBindingTokens = lark.NewBindingTokenService(queries) + slog.Info("lark integration enabled") + } + } + } else { + slog.Info("lark integration disabled (MULTICA_LARK_SECRET_KEY not set)") + } if opts.HeartbeatScheduler != nil { h.HeartbeatScheduler = opts.HeartbeatScheduler } @@ -363,9 +390,33 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/github/connect", h.GitHubConnect) r.Delete("/github/installations/{installationId}", h.DeleteGitHubInstallation) }) + + // Lark integration. Listing is member-visible (same + // rationale as GitHub: the Integrations tab must + // render for non-admins so they see "wired up by whom"). + // Create / revoke require admin to prevent a non-admin + // from binding a Bot to a workspace agent or yanking + // an installation out from under one. + r.Group(func(r chi.Router) { + r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id")) + r.Get("/lark/installations", h.ListLarkInstallations) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin")) + r.Post("/lark/installations", h.CreateLarkInstallation) + r.Delete("/lark/installations/{installationId}", h.RevokeLarkInstallation) + }) }) }) + // Lark binding-token redemption. NOT workspace-scoped because + // the redeemer hits this BEFORE they have any workspace + // context — the redemption itself is what mints their + // lark_user_binding row. Identity comes from the session; + // the token only proves "this open_id requested binding," and + // is combined with the logged-in user to create the mapping. + r.Post("/api/lark/binding/redeem", h.RedeemLarkBindingToken) + // User-scoped invitation routes (no workspace context required) r.Get("/api/invitations", h.ListMyInvitations) r.Get("/api/invitations/{id}", h.GetMyInvitation) diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index e0fcc89e0..226d4b0c0 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -20,6 +20,7 @@ import ( "github.com/multica-ai/multica/server/internal/cloudruntime" "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/integrations/lark" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" @@ -106,7 +107,14 @@ type Handler struct { WebhookRateLimiter WebhookRateLimiter WebhookIPRateLimiter WebhookRateLimiter CloudRuntime cloudRuntimeProxy - cfg Config + // Lark integration. Both are nil when the Lark master key + // (MULTICA_LARK_SECRET_KEY) is unset; the corresponding HTTP + // handlers return 503 in that case so a misconfigured self-host + // deployment surfaces a clear error instead of silently using a + // zero key. Wired in cmd/server/router.go after handler.New. + LarkInstallations *lark.InstallationService + LarkBindingTokens *lark.BindingTokenService + cfg Config } func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler { diff --git a/server/internal/handler/lark.go b/server/internal/handler/lark.go new file mode 100644 index 000000000..4ddefdee0 --- /dev/null +++ b/server/internal/handler/lark.go @@ -0,0 +1,278 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "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"` + 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, + 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 +} + +// CreateLarkInstallationRequest is the manual-install payload (admin +// pastes credentials from the Lark developer console). The OAuth +// callback path lands credentials via the same InstallationService +// pipeline, so adding it later does not change this contract. +type CreateLarkInstallationRequest struct { + AgentID string `json:"agent_id"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + TenantKey string `json:"tenant_key,omitempty"` + BotOpenID string `json:"bot_open_id"` +} + +// CreateLarkInstallation (POST /api/workspaces/{id}/lark/installations) +// is admin-only at the router level. It performs the at-rest +// encryption of `app_secret` via InstallationService and refuses to +// fall back to plaintext storage when the master key is unset (503). +func (h *Handler) CreateLarkInstallation(w http.ResponseWriter, r *http.Request) { + if h.LarkInstallations == nil { + writeError(w, http.StatusServiceUnavailable, "lark integration not configured (MULTICA_LARK_SECRET_KEY)") + return + } + userID, ok := requireUserID(w, r) + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace id") + if !ok { + return + } + + var req CreateLarkInstallationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + agentUUID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id") + if !ok { + return + } + // Validate the agent really belongs to this workspace before we + // accept its credentials. Without this guard a workspace admin + // could install Lark on an agent in a different workspace by + // supplying that workspace's agent_id. + 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 + } + installerUUID, ok := parseUUIDOrBadRequest(w, userID, "installer user id") + if !ok { + return + } + + inst, err := h.LarkInstallations.Upsert(r.Context(), lark.InstallationParams{ + WorkspaceID: wsUUID, + AgentID: agentUUID, + AppID: req.AppID, + AppSecret: req.AppSecret, + TenantKey: req.TenantKey, + BotOpenID: req.BotOpenID, + InstallerUserID: installerUUID, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + resp := larkInstallationToResponse(inst) + h.publish(protocol.EventLarkInstallationCreated, uuidToString(wsUUID), "user", userID, resp) + writeJSON(w, http.StatusCreated, 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. +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, + }) + 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, + }) +} + +// 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 +// CreateLarkInstallation 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 via " 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. +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.Redeem(r.Context(), req.Token) + if err != nil { + if errors.Is(err, lark.ErrBindingTokenInvalid) { + writeError(w, http.StatusGone, "binding token invalid or expired") + return + } + writeError(w, http.StatusInternalServerError, "failed to redeem token") + return + } + + // CreateLarkUserBinding's composite FK to member(workspace_id, user_id) + // will fail with a foreign-key violation if the redeemer is NOT a + // member of the token's workspace. We surface that as 403 so a + // non-member who stole the link cannot silently complete binding. + if _, err := h.Queries.CreateLarkUserBinding(r.Context(), db.CreateLarkUserBindingParams{ + WorkspaceID: redeemed.WorkspaceID, + MulticaUserID: userUUID, + InstallationID: redeemed.InstallationID, + LarkOpenID: string(redeemed.LarkOpenID), + }); err != nil { + writeError(w, http.StatusForbidden, "binding refused (are you a workspace member?)") + return + } + + writeJSON(w, http.StatusOK, RedeemLarkBindingTokenResponse{ + WorkspaceID: uuidToString(redeemed.WorkspaceID), + InstallationID: uuidToString(redeemed.InstallationID), + LarkOpenID: string(redeemed.LarkOpenID), + }) +} diff --git a/server/internal/handler/lark_test.go b/server/internal/handler/lark_test.go new file mode 100644 index 000000000..36cca969a --- /dev/null +++ b/server/internal/handler/lark_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Lark-handler unit tests focus on the no-config short-circuits — +// verifying that a self-host deployment without MULTICA_LARK_SECRET_KEY +// does NOT serve create / revoke / redeem, and that list degrades +// gracefully to an empty response so the Integrations tab still +// renders. Happy-path flows (create + list + revoke; token mint + +// redeem) need a real DB and land alongside the WS hub integration +// tests in a follow-up commit. + +func TestCreateLarkInstallation_NotConfigured(t *testing.T) { + h := &Handler{} // LarkInstallations intentionally nil + req := httptest.NewRequest(http.MethodPost, "/api/workspaces/x/lark/installations", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + h.CreateLarkInstallation(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when lark not configured, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestRevokeLarkInstallation_NotConfigured(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodDelete, "/api/workspaces/x/lark/installations/y", nil) + w := httptest.NewRecorder() + h.RevokeLarkInstallation(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestRedeemLarkBindingToken_NotConfigured(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/api/lark/binding/redeem", strings.NewReader(`{"token":"x"}`)) + w := httptest.NewRecorder() + h.RedeemLarkBindingToken(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestListLarkInstallations_NotConfiguredReturnsEmpty(t *testing.T) { + // Listing is intentionally a "soft" endpoint: when lark is not + // configured we return an empty list + configured:false rather + // than a 503, so the Integrations tab renders normally with a + // "not connected" empty state instead of an error banner. + h := &Handler{} + req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/installations", nil) + w := httptest.NewRecorder() + h.ListLarkInstallations(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + var resp struct { + Installations []any `json:"installations"` + Configured bool `json:"configured"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Configured { + t.Fatalf("configured should be false when LarkInstallations is nil") + } + if len(resp.Installations) != 0 { + t.Fatalf("expected empty installations list, got %d", len(resp.Installations)) + } +} diff --git a/server/internal/integrations/lark/installation.go b/server/internal/integrations/lark/installation.go new file mode 100644 index 000000000..34d258343 --- /dev/null +++ b/server/internal/integrations/lark/installation.go @@ -0,0 +1,157 @@ +package lark + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/util/secretbox" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// InstallationParams is the input shape both the manual-install HTTP +// handler and the OAuth callback assemble into. The credentials are +// supplied here as plaintext — encryption happens inside +// InstallationService.Upsert via the supplied *secretbox.Box, so +// callers never see (and therefore cannot leak) the ciphertext that +// lands in the DB. +type InstallationParams struct { + WorkspaceID pgtype.UUID + AgentID pgtype.UUID + AppID string + AppSecret string // plaintext; encrypted at the service boundary + TenantKey string // optional, "" treated as NULL + BotOpenID string + InstallerUserID pgtype.UUID +} + +// InstallationService creates, refreshes and revokes per-agent Lark +// installations. It owns the at-rest encryption of `app_secret` so +// that no caller (and no test fixture) can accidentally insert a row +// with plaintext credentials — the only path to writing +// lark_installation goes through here. +type InstallationService struct { + queries *db.Queries + box *secretbox.Box +} + +// NewInstallationService binds the service to a queries handle and a +// secretbox keyed for at-rest encryption. The box MUST be non-nil; we +// refuse to fall back to plaintext storage even in test or dev +// configurations because that is exactly the regression the §4.4 +// requirement guards against. +func NewInstallationService(queries *db.Queries, box *secretbox.Box) (*InstallationService, error) { + if box == nil { + return nil, errors.New("lark: InstallationService requires a non-nil secretbox.Box") + } + return &InstallationService{queries: queries, box: box}, nil +} + +// Upsert creates a new installation or refreshes an existing one in +// place (matching on the (workspace_id, agent_id) UNIQUE). Re-install +// resets status to 'active' but does NOT touch the WS lease — that is +// the hub's concern, not ours. The returned row is the post-write +// state; the encrypted secret column is included for completeness but +// callers SHOULD NOT log or persist it elsewhere. +func (s *InstallationService) Upsert(ctx context.Context, p InstallationParams) (db.LarkInstallation, error) { + if err := validateInstallationParams(p); err != nil { + return db.LarkInstallation{}, err + } + sealed, err := s.box.Seal([]byte(p.AppSecret)) + if err != nil { + return db.LarkInstallation{}, fmt.Errorf("encrypt app_secret: %w", err) + } + return s.queries.UpsertLarkInstallation(ctx, db.UpsertLarkInstallationParams{ + WorkspaceID: p.WorkspaceID, + AgentID: p.AgentID, + AppID: p.AppID, + AppSecretEncrypted: sealed, + TenantKey: textOrNull(p.TenantKey), + BotOpenID: p.BotOpenID, + InstallerUserID: p.InstallerUserID, + }) +} + +// Revoke flips status to 'revoked' so the WS hub tears the connection +// down on its next sweep and the dispatcher drops any in-flight +// events. The row is preserved (no DELETE) so audit history remains +// queryable; a subsequent re-install via Upsert flips status back to +// 'active' atomically. +func (s *InstallationService) Revoke(ctx context.Context, id pgtype.UUID) error { + return s.queries.SetLarkInstallationStatus(ctx, db.SetLarkInstallationStatusParams{ + ID: id, + Status: string(InstallationRevoked), + }) +} + +// DecryptAppSecret returns the plaintext app_secret for the supplied +// installation row. Used by the WebSocket hub when it needs to +// authenticate against the Lark API on behalf of an installation; do +// NOT use this for read-only display surfaces. The plaintext value +// must never round-trip through an HTTP response. +func (s *InstallationService) DecryptAppSecret(inst db.LarkInstallation) (string, error) { + plain, err := s.box.Open(inst.AppSecretEncrypted) + if err != nil { + return "", fmt.Errorf("decrypt app_secret: %w", err) + } + return string(plain), nil +} + +// GetInWorkspace is the workspace-scoped lookup helper. Internal +// callers (Dispatcher) use GetLarkInstallationByAppID directly because +// the event payload only carries app_id; HTTP-side callers always +// know the workspace and should use this so a forged installation_id +// from a different workspace returns NotFound instead of leaking +// existence. +func (s *InstallationService) GetInWorkspace(ctx context.Context, id, workspaceID pgtype.UUID) (db.LarkInstallation, error) { + row, err := s.queries.GetLarkInstallationInWorkspace(ctx, db.GetLarkInstallationInWorkspaceParams{ + ID: id, + WorkspaceID: workspaceID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return db.LarkInstallation{}, ErrInstallationNotFound + } + return db.LarkInstallation{}, err + } + return row, nil +} + +// ListByWorkspace returns every installation rooted at the workspace, +// active and revoked, oldest first. The status column lets the UI +// distinguish "wired up" from "torn down but kept for audit". +func (s *InstallationService) ListByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]db.LarkInstallation, error) { + return s.queries.ListLarkInstallationsByWorkspace(ctx, workspaceID) +} + +// ErrInstallationNotFound surfaces "no row matches in this workspace" +// — used by the HTTP layer to return 404. Distinct from a plain +// pgx.ErrNoRows so handlers do not need to import pgx. +var ErrInstallationNotFound = errors.New("lark installation not found") + +func validateInstallationParams(p InstallationParams) error { + switch { + case !p.WorkspaceID.Valid: + return errors.New("workspace_id is required") + case !p.AgentID.Valid: + return errors.New("agent_id is required") + case !p.InstallerUserID.Valid: + return errors.New("installer_user_id is required") + case p.AppID == "": + return errors.New("app_id is required") + case p.AppSecret == "": + return errors.New("app_secret is required") + case p.BotOpenID == "": + return errors.New("bot_open_id is required") + } + return nil +} + +func textOrNull(s string) pgtype.Text { + if s == "" { + return pgtype.Text{} + } + return pgtype.Text{String: s, Valid: true} +} diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index dd736ba68..6eae12d2b 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -122,4 +122,13 @@ const ( EventPullRequestLinked = "pull_request:linked" EventPullRequestUpdated = "pull_request:updated" EventPullRequestUnlinked = "pull_request:unlinked" + + // Lark integration events. `created` covers both first-install + // (UNIQUE on (workspace_id, agent_id) means at most one row per + // agent) and re-install via UpsertLarkInstallation — front-ends + // treat both as a single "installation appeared / refreshed" + // notification. `revoked` flips status to 'revoked' without + // deleting the row; the audit trail is preserved. + EventLarkInstallationCreated = "lark_installation:created" + EventLarkInstallationRevoked = "lark_installation:revoked" )