multica-ai/multica · error

wecom: redeemer is not a workspace member

Error message

wecom: redeemer is not a workspace member

What it means

WeCom binding sentinel error: the redeemer is not a member of the token's workspace. Enforced by an explicit membership check during redemption (the member FK no longer exists); the HTTP boundary translates it to 403.

Source

Thrown at server/internal/integrations/wecom/binding.go:78

// user who keeps going, against rows that expire in fifteen. The price of
// being wrong is one more message, not ten minutes of a bot insisting it
// already answered.
//
// It must stay comfortably inside BindingTokenTTL so a link a throttled user
// is pointed back at still has real time left on it.
const BindingTokenMintInterval = time.Minute

var (
	// ErrBindingTokenInvalid: token unknown / already consumed / expired.
	// One opaque error for all three avoids a replay timing oracle.
	ErrBindingTokenInvalid = errors.New("wecom: binding token invalid or expired")
	// ErrBindingAlreadyAssigned: this WeCom userid is already bound to a
	// different Multica user (account transfer must go through explicit
	// unbind, not implemented in iter 1 — an admin can DELETE the row).
	ErrBindingAlreadyAssigned = errors.New("wecom: user id is already bound to a different user")
	// ErrBindingNotWorkspaceMember: the redeemer is not a member of the
	// token's workspace. Translated to 403 at the HTTP boundary.
	ErrBindingNotWorkspaceMember = errors.New("wecom: redeemer is not a workspace member")
)

// BindingToken is a freshly minted token. The raw value is returned exactly
// once (embedded in the binding URL); only its hash is persisted.
type BindingToken struct {
	Raw       string
	ExpiresAt time.Time

	// Reused says the throttle suppressed the mint because a live link is
	// already sitting in the user's chat. Raw is empty in that case and there
	// is no way to recover it — the table only ever held the hash — so the
	// caller must point the user back at the earlier message rather than
	// building a URL. ExpiresAt carries the live token's expiry, not a fresh
	// one's.
	Reused bool
}

// RedeemedBindingToken is returned after a successful redemption.

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Have an admin add the redeeming user to the workspace, then request a new binding link (old one may have expired).
  2. Confirm the browser session is the intended member account before redeeming.
  3. If membership is believed valid, inspect the membership rows for staleness.

Example fix

// before
_, err := svc.Redeem(ctx, rawToken)
if err != nil {
	http.Error(w, err.Error(), 500)
}

// after
_, err := svc.Redeem(ctx, rawToken)
if errors.Is(err, wecom.ErrBindingNotWorkspaceMember) {
	http.Error(w, "you must be a member of this workspace", http.StatusForbidden)
	return
}
Defensive patterns

Strategy: validation

Validate before calling

if !members.IsMember(ctx, token.WorkspaceID, user.ID) {
	return respondForbidden(w, "join the workspace before linking WeCom")
}
_ = wecomSvc.Redeem(ctx, rawToken)

Try / catch

if err := wecomSvc.Redeem(ctx, rawToken); err != nil {
	if errors.Is(err, wecom.ErrBindingNotWorkspaceMember) {
		return respondStatus(w, http.StatusForbidden)
	}
	return err
}

Prevention

When it happens

Trigger: Redeeming a WeCom binding token by a user with no membership in the workspace the token was minted for — removed mid-flow, or signed in under an account that never joined.

Common situations: User removed from the workspace between the bot message and the redeem click; wrong account active in the browser session when clicking the link.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/a5bebee4be4274e9. Report an issue: GitHub.