feat(integrations/lark): installation HTTP surface + secretbox-gated wiring (MUL-2671)

Lands the HTTP boundary on top of the inbound services from the
previous commit. What ships:

- InstallationService.Upsert: the only path that writes
  lark_installation. Encrypts app_secret with the secretbox passed in
  at construction time; refuses to fall back to plaintext storage
  (returns an error from the constructor if no Box is supplied), so a
  misconfigured dev environment cannot accidentally land a row with
  cleartext credentials. Revoke flips status without DELETE so audit
  trail survives.

- HTTP handlers under /api/workspaces/{id}/lark/:
  * GET  /installations           — member-visible (Integrations tab
                                    renders for non-admins). Soft 200
                                    with empty list + configured:false
                                    when MULTICA_LARK_SECRET_KEY is
                                    unset, so the tab does not error
                                    on self-host that has not opted in.
  * POST /installations           — admin-only; 503 when not configured.
                                    Re-validates agent_id ∈ workspace
                                    before accepting credentials so a
                                    cross-workspace agent UUID is
                                    rejected.
  * DELETE /installations/{id}    — admin-only; workspace-scoped lookup
                                    so one workspace cannot revoke
                                    another's installation by UUID
                                    guess.

- POST /api/lark/binding/redeem (user-scoped, no workspace context):
  the only path that mints a lark_user_binding row from user action.
  Redeemer identity comes from the session, not the token, so a stolen
  link cannot bind an open_id to an attacker's Multica user. The
  composite FK on lark_user_binding cascades the binding away if the
  user is not (or no longer) a workspace member, so a non-member who
  steals the link gets 403 at the DB layer.

- Two new event-bus types in protocol.events:
  EventLarkInstallationCreated, EventLarkInstallationRevoked.

- Router wiring: MULTICA_LARK_SECRET_KEY drives a conditional
  initialization of h.LarkInstallations + h.LarkBindingTokens. When
  unset, the integration disables itself with an INFO log and the
  rest of the server boots normally.

- Handler tests cover all four not-configured short-circuits.
  Happy-path integration tests (real DB, full create→list→revoke
  cycle and token mint→redeem) ship alongside the WS hub PR.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiang Bohan
2026-05-26 18:58:01 +08:00
co-authored by multica-agent
parent 79dc637002
commit 87ad15e167
6 changed files with 578 additions and 1 deletions
+51
View File
@@ -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)
+9 -1
View File
@@ -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 {
+278
View File
@@ -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 <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.
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),
})
}
+74
View File
@@ -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))
}
}
@@ -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}
}
+9
View File
@@ -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"
)