multica-ai/multica · error · ErrBindingNotWorkspaceMember
redeemer is not a workspace member
Error message
redeemer is not a workspace member
What it means
Lark binding sentinel error: the user redeeming the binding token is not (or no longer) a member of the target workspace. Since MUL-3515 removed the member foreign key, membership is enforced with an explicit IsWorkspaceMember check inside RedeemAndBind and BindInstallerTx, and the HTTP layer translates this error to 403.
Source
Thrown at server/internal/integrations/lark/binding_token.go:287
// has expired. The caller must NOT distinguish those sub-cases —
// that distinction enables timing oracles for token replay races and
// adds no product value (the user sees the same "link invalid or
// expired, please request a new one" copy either way).
var ErrBindingTokenInvalid = errors.New("binding token invalid or expired")
// ErrBindingAlreadyAssigned is returned by RedeemAndBind when a
// lark_user_binding row already exists for the (installation,
// open_id) pair and points at a different Multica user. Account
// transfer must go through an explicit unbind flow; a binding token
// cannot be used to grab an already-bound open_id from another user.
var ErrBindingAlreadyAssigned = errors.New("lark open_id is already bound to a different user")
// ErrBindingNotWorkspaceMember is returned by RedeemAndBind and
// BindInstallerTx when the user is not (or no longer) a member of the
// target workspace, detected by an explicit IsWorkspaceMember check
// (MUL-3515 §4 removed the member FK that used to enforce this).
// Translated to 403 at the HTTP boundary.
var ErrBindingNotWorkspaceMember = errors.New("redeemer is not a workspace member")
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
// URL-safe so the token embeds cleanly in the binding URL
// without escaping. RawURLEncoding drops `=` padding which is
// optional for decoders and would otherwise look ugly in
// user-visible URLs.
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
View on GitHub (pinned to 2c0912b6ec)
Solutions
- Confirm the redeemer's membership in the token's target workspace and re-add them if they were removed, then request a new binding link (the old token may have expired in the meantime).
- Check that the redeem request carries the correct workspace context (the token encodes the workspace) and that the signed-in user matches the intended member.
- If membership exists but the error persists, verify the IsWorkspaceMember query's inputs (workspace_id, user_id) against the actual membership rows for drift after MUL-3515.
Example fix
// before: assuming any signed-in user can redeem
err := svc.RedeemAndBind(ctx, rawToken, sessionUser)
// -> "redeemer is not a workspace member" (403)
// after: pre-check membership and fail with actionable copy
if !members.IsWorkspaceMember(ctx, tx, tokenWorkspaceID, sessionUser.ID) {
http.Error(w, "join the workspace before linking", http.StatusForbidden)
return
}
err := svc.RedeemAndBind(ctx, rawToken, sessionUser) Defensive patterns
Strategy: validation
Validate before calling
// pre-check membership before showing/redeeming the link
if !members.IsWorkspaceMember(ctx, tx, token.WorkspaceID, user.ID) {
return respondForbidden(w, "join this workspace before linking Lark")
}
_ = larkSvc.RedeemAndBind(ctx, token, user) Try / catch
if err := larkSvc.RedeemAndBind(ctx, rawToken, user); err != nil {
if errors.Is(err, lark.ErrBindingNotWorkspaceMember) {
return respondStatus(w, http.StatusForbidden)
}
return err
} Prevention
- Show binding links only to current workspace members; re-check at redeem time since removal can happen inside the TTL.
- Keep membership rows accurate — since MUL-3515 removed the FK, drift is caught only by this explicit check.
- Include the workspace name in the 403 copy so multi-workspace users realize they clicked the wrong context.
When it happens
Trigger: Calling RedeemAndBind or BindInstallerTx after the redeemer was removed from the workspace between token mint and redemption, or when the token was minted in workspace A but redeemed by a user whose membership is in workspace B. The membership check runs inside the redemption transaction, so any non-member redeemer hits it.
Common situations: User was kicked off the workspace during the 15-minute token TTL and then clicks the stale link; user belongs to multiple workspaces and the binding URL carried the wrong workspace context; membership rows out of sync after an org change (the FK that used to catch this no longer exists).
Related errors
- slack: redeemer is not a workspace member
- wecom: redeemer is not a workspace member
- lark open_id is already bound to a different user
- Invalid desktop runtime config JSON: ${err instanceof Error
- repo is not configured for this workspace
AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15).
Data as JSON: /api/errors/924cff57c560cb73.
Report an issue: GitHub.