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

  1. 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).
  2. 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.
  3. 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

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


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