{"record":{"id":"a5bebee4be4274e9","repo":"multica-ai/multica","slug":"wecom-redeemer-is-not-a-workspace-member","errorCode":null,"errorMessage":"wecom: redeemer is not a workspace member","messagePattern":"wecom: redeemer is not a workspace member","errorType":"exception","errorClass":null,"httpStatus":403,"severity":"error","filePath":"server/internal/integrations/wecom/binding.go","lineNumber":78,"sourceCode":"// user who keeps going, against rows that expire in fifteen. The price of\n// being wrong is one more message, not ten minutes of a bot insisting it\n// already answered.\n//\n// It must stay comfortably inside BindingTokenTTL so a link a throttled user\n// is pointed back at still has real time left on it.\nconst BindingTokenMintInterval = time.Minute\n\nvar (\n\t// ErrBindingTokenInvalid: token unknown / already consumed / expired.\n\t// One opaque error for all three avoids a replay timing oracle.\n\tErrBindingTokenInvalid = errors.New(\"wecom: binding token invalid or expired\")\n\t// ErrBindingAlreadyAssigned: this WeCom userid is already bound to a\n\t// different Multica user (account transfer must go through explicit\n\t// unbind, not implemented in iter 1 — an admin can DELETE the row).\n\tErrBindingAlreadyAssigned = errors.New(\"wecom: user id is already bound to a different user\")\n\t// ErrBindingNotWorkspaceMember: the redeemer is not a member of the\n\t// token's workspace. Translated to 403 at the HTTP boundary.\n\tErrBindingNotWorkspaceMember = errors.New(\"wecom: redeemer is not a workspace member\")\n)\n\n// BindingToken is a freshly minted token. The raw value is returned exactly\n// once (embedded in the binding URL); only its hash is persisted.\ntype BindingToken struct {\n\tRaw       string\n\tExpiresAt time.Time\n\n\t// Reused says the throttle suppressed the mint because a live link is\n\t// already sitting in the user's chat. Raw is empty in that case and there\n\t// is no way to recover it — the table only ever held the hash — so the\n\t// caller must point the user back at the earlier message rather than\n\t// building a URL. ExpiresAt carries the live token's expiry, not a fresh\n\t// one's.\n\tReused bool\n}\n\n// RedeemedBindingToken is returned after a successful redemption.","sourceCodeStart":60,"sourceCodeEnd":96,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/integrations/wecom/binding.go#L60-L96","documentation":"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.","triggerScenarios":"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.","commonSituations":"User removed from the workspace between the bot message and the redeem click; wrong account active in the browser session when clicking the link.","solutions":["Have an admin add the redeeming user to the workspace, then request a new binding link (old one may have expired).","Confirm the browser session is the intended member account before redeeming.","If membership is believed valid, inspect the membership rows for staleness."],"exampleFix":"// before\n_, err := svc.Redeem(ctx, rawToken)\nif err != nil {\n\thttp.Error(w, err.Error(), 500)\n}\n\n// after\n_, err := svc.Redeem(ctx, rawToken)\nif errors.Is(err, wecom.ErrBindingNotWorkspaceMember) {\n\thttp.Error(w, \"you must be a member of this workspace\", http.StatusForbidden)\n\treturn\n}","handlingStrategy":"validation","validationCode":"if !members.IsMember(ctx, token.WorkspaceID, user.ID) {\n\treturn respondForbidden(w, \"join the workspace before linking WeCom\")\n}\n_ = wecomSvc.Redeem(ctx, rawToken)","typeGuard":null,"tryCatchPattern":"if err := wecomSvc.Redeem(ctx, rawToken); err != nil {\n\tif errors.Is(err, wecom.ErrBindingNotWorkspaceMember) {\n\t\treturn respondStatus(w, http.StatusForbidden)\n\t}\n\treturn err\n}","preventionTips":["Verify the active browser account matches a workspace member before showing the redeem page.","Re-check membership at redeem time; the 15-minute TTL window is long enough for removals.","Return 403 (not 400) for this case so clients can offer a workspace-switch prompt."],"tags":["wecom","binding","authorization","workspace","go"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}