{"record":{"id":"924cff57c560cb73","repo":"multica-ai/multica","slug":"redeemer-is-not-a-workspace-member","errorCode":null,"errorMessage":"redeemer is not a workspace member","messagePattern":"redeemer is not a workspace member","errorType":"exception","errorClass":"ErrBindingNotWorkspaceMember","httpStatus":403,"severity":"error","filePath":"server/internal/integrations/lark/binding_token.go","lineNumber":287,"sourceCode":"// has expired. The caller must NOT distinguish those sub-cases —\n// that distinction enables timing oracles for token replay races and\n// adds no product value (the user sees the same \"link invalid or\n// expired, please request a new one\" copy either way).\nvar ErrBindingTokenInvalid = errors.New(\"binding token invalid or expired\")\n\n// ErrBindingAlreadyAssigned is returned by RedeemAndBind when a\n// lark_user_binding row already exists for the (installation,\n// open_id) pair and points at a different Multica user. Account\n// transfer must go through an explicit unbind flow; a binding token\n// cannot be used to grab an already-bound open_id from another user.\nvar ErrBindingAlreadyAssigned = errors.New(\"lark open_id is already bound to a different user\")\n\n// ErrBindingNotWorkspaceMember is returned by RedeemAndBind and\n// BindInstallerTx when the user is not (or no longer) a member of the\n// target workspace, detected by an explicit IsWorkspaceMember check\n// (MUL-3515 §4 removed the member FK that used to enforce this).\n// Translated to 403 at the HTTP boundary.\nvar ErrBindingNotWorkspaceMember = errors.New(\"redeemer is not a workspace member\")\n\nfunc randomToken(n int) (string, error) {\n\tbuf := make([]byte, n)\n\tif _, err := rand.Read(buf); err != nil {\n\t\treturn \"\", err\n\t}\n\t// URL-safe so the token embeds cleanly in the binding URL\n\t// without escaping. RawURLEncoding drops `=` padding which is\n\t// optional for decoders and would otherwise look ugly in\n\t// user-visible URLs.\n\treturn base64.RawURLEncoding.EncodeToString(buf), nil\n}\n\nfunc hashToken(raw string) string {\n\tsum := sha256.Sum256([]byte(raw))\n\treturn hex.EncodeToString(sum[:])\n}\n","sourceCodeStart":269,"sourceCodeEnd":305,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/integrations/lark/binding_token.go#L269-L305","documentation":"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.","triggerScenarios":"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.","commonSituations":"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).","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."],"exampleFix":"// before: assuming any signed-in user can redeem\nerr := svc.RedeemAndBind(ctx, rawToken, sessionUser)\n// -> \"redeemer is not a workspace member\" (403)\n\n// after: pre-check membership and fail with actionable copy\nif !members.IsWorkspaceMember(ctx, tx, tokenWorkspaceID, sessionUser.ID) {\n\thttp.Error(w, \"join the workspace before linking\", http.StatusForbidden)\n\treturn\n}\nerr := svc.RedeemAndBind(ctx, rawToken, sessionUser)","handlingStrategy":"validation","validationCode":"// pre-check membership before showing/redeeming the link\nif !members.IsWorkspaceMember(ctx, tx, token.WorkspaceID, user.ID) {\n\treturn respondForbidden(w, \"join this workspace before linking Lark\")\n}\n_ = larkSvc.RedeemAndBind(ctx, token, user)","typeGuard":null,"tryCatchPattern":"if err := larkSvc.RedeemAndBind(ctx, rawToken, user); err != nil {\n\tif errors.Is(err, lark.ErrBindingNotWorkspaceMember) {\n\t\treturn respondStatus(w, http.StatusForbidden)\n\t}\n\treturn err\n}","preventionTips":["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."],"tags":["lark","binding","authorization","workspace","go"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}